From 0e8c4fb381ef4d41ceb948fc408f4b52ab5f545d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 19 Nov 2024 16:30:04 +0100 Subject: [PATCH 001/336] WIP class skeleton --- .../html-api/class-wp-css-selector.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/wp-includes/html-api/class-wp-css-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php new file mode 100644 index 0000000000000..7ec6b5a69ced2 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -0,0 +1,31 @@ + Date: Wed, 20 Nov 2024 16:57:19 +0100 Subject: [PATCH 002/336] Document class --- .../html-api/class-wp-css-selector.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 7ec6b5a69ced2..1684aefef2024 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -12,20 +12,47 @@ * * This class is designed for internal use by the HTML processor. * + * This class is instantiated via the `WP_CSS_Selector::from_selector( string $selector )` method. + * It accepts a CSS selector string and returns an instance of itself or `null` if the selector + * is invalid or unsupported. + * + * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax + * specification, which is available at https://www.w3.org/TR/css-syntax-3/. + * + * Supported selector syntax: + * - Type selectors (tag names, e.g. `div`) + * - Class selectors (e.g. `.class-name`) + * - ID selectors (e.g. `#unique-id`) + * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) + * - The following combinators: + * - descendant (e.g. `.parent .descendant`) + * - child (`.parent > .child`) + * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) + * + * Unsupported selector syntax: + * - The following combinators: + * - Next sibling (`.sibling + .sibling`) + * - Subsequent sibling (`.sibling ~ .sibling`) + * - Pseudo-element selectors (e.g. `::before`) + * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * * @since TBD * * @access private * + * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token * @see https://www.w3.org/tr/selectors/#parse-selector + * */ class WP_CSS_Selector { private function __construct() {} /** - * @return static + * @return static|null */ public static function from_selector( string $selector ) { $res = new static(); return $res; } + } From 40222d30200afdf998586cb127c35c880bfe7df8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 21 Nov 2024 11:57:28 +0100 Subject: [PATCH 003/336] Do not support namespaced selectors --- src/wp-includes/html-api/class-wp-css-selector.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 1684aefef2024..fb8934bec06f4 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -35,6 +35,7 @@ * - Subsequent sibling (`.sibling ~ .sibling`) * - Pseudo-element selectors (e.g. `::before`) * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * - Namespace prefixes that need to be resolved (e.g. `svg|title` or `[xlink|href]`) * * @since TBD * From 60926421295e58229637891c853ab50f0920ae23 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 16:04:42 +0100 Subject: [PATCH 004/336] Flesh out stuff --- .../html-api/class-wp-css-selector.php | 59 ----- .../html-api/class-wp-css-selectors.php | 248 ++++++++++++++++++ 2 files changed, 248 insertions(+), 59 deletions(-) delete mode 100644 src/wp-includes/html-api/class-wp-css-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-selectors.php diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php deleted file mode 100644 index fb8934bec06f4..0000000000000 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ /dev/null @@ -1,59 +0,0 @@ - .child`) - * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) - * - * Unsupported selector syntax: - * - The following combinators: - * - Next sibling (`.sibling + .sibling`) - * - Subsequent sibling (`.sibling ~ .sibling`) - * - Pseudo-element selectors (e.g. `::before`) - * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) - * - Namespace prefixes that need to be resolved (e.g. `svg|title` or `[xlink|href]`) - * - * @since TBD - * - * @access private - * - * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token - * @see https://www.w3.org/tr/selectors/#parse-selector - * - */ -class WP_CSS_Selector { - private function __construct() {} - - /** - * @return static|null - */ - public static function from_selector( string $selector ) { - $res = new static(); - return $res; - } - -} diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php new file mode 100644 index 0000000000000..acc5db02752c3 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -0,0 +1,248 @@ + .child`) + * + * Unsupported selector syntax: + * - Pseudo-element selectors (e.g. `::before`) + * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) + * - The following combinators: + * - Next sibling (`.sibling + .sibling`) + * - Subsequent sibling (`.sibling ~ .sibling`) + * + * @since TBD + * + * @access private + * + * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token + * @see https://www.w3.org/tr/selectors/#parse-selector + * @see https://www.w3.org/TR/selectors-api2/ + * @see https://www.w3.org/TR/selectors-4/ + * + */ +class WP_CSS_Selectors { + + /** + * Takes a CSS selectors string and returns an instance of itself or `null` if the selector + * is invalid or unsupported. + * + * @since TBD + * + * @param string $selectors CSS selectors string. + * @return static|null + */ + public static function from_selectors( string $selectors ) { + $res = new static(); + return $res; + } + + /** + * Returns a list of selectors. + * + * @since TBD + * + * @return WP_CSS_Selector[] + */ + private static function parse( string $input ) { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. + $input = trim( $input, " \t\r\n\r" ); + + if ( '' === $input ) { + null; + } + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + $at = 0; + $length = strlen( $input ); + $selectors = array(); + + $at = strspn( $input, "\n\t ", $at ); + while ( $at < $length ) { + } + } +} + +interface IWP_CSS_Selector_Parser { + public static function parse( string $input, string $offset, ?int $consumed_bytes = null ): ?self; +} + +abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { + public static function parse_whitespace( string $input, string &$offset ): bool { + $length = strspn( $input, " \t\r\n\f", $offset ); + $advanced = $length > 0; + $offset += $length; + return $advanced; + } + + /* + * Utiltities + * ========== + * + * The following functions do not consume any input. + */ + + /** + * > 4.3.8. Check if two code points are a valid escape + * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. + * > + * > Note: This algorithm will not consume any additional code point. + * > + * > If the first code point is not U+005C REVERSE SOLIDUS (\), return false. + * > + * > Otherwise, if the second code point is a newline, return false. + * > + * > Otherwise, return true. + * + * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape + * + * @todo this does not check whether the second codepoint is valid. + */ + public static function next_two_are_valid_escape( string $input, string $offset ): bool { + if ( $offset + 1 >= strlen( $input ) ) { + return false; + } + return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; + } + + /** + * > ident-start code point + * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). + * > uppercase letter + * > A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. + * > lowercase letter + * > A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. + * > letter + * > An uppercase letter or a lowercase letter. + * > non-ASCII code point + * > A code point with a value equal to or greater than U+0080 . + */ + public static function is_ident_start_codepoint( string $input, string $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + return ( + '_' === $input[ $offset ] || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || + $input[ $offset ] <= '\x7F' + ); + } + + /** + * > ident code point + * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + * > digit + * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + */ + public static function is_ident_codepoint( string $input, string $offset ): bool { + return '-' === $input[ $offset ] || + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * > 4.3.9. Check if three code points would start an ident sequence + * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. + * > + * > Note: This algorithm will not consume any additional code points. + * > + * > Look at the first code point: + * > + * > U+002D HYPHEN-MINUS + * > If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, or the second and third code points are a valid escape, return true. Otherwise, return false. + * > ident-start code point + * > Return true. + * > U+005C REVERSE SOLIDUS (\) + * > If the first and second code points are a valid escape, return true. Otherwise, return false. + * > anything else + * > Return false. + * + * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + */ + public static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > U+005C REVERSE SOLIDUS (\) + if ( '\\' === $input[ $offset ] ) { + return self::next_two_are_valid_escape( $input, $offset ); + } + + // > U+002D HYPHEN-MINUS + if ( '-' === $input[ $offset ] ) { + $after_initial_hyphen_minus_offset = $offset + 1; + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > If the second code point is… U+002D HYPHEN-MINUS… return true + if ( '-' === $input[ $after_initial_hyphen_minus_offset ] ) { + return true; + } + + // > If the second and third code points are a valid escape, return true. + if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > If the second code point is an ident-start code point… return true. + if ( self::is_ident_start_codepoint( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > Otherwise, return false. + return false; + } + + // > ident-start code point + // > Return true. + // > anything else + // > Return false. + return self::is_ident_start_codepoint( $input, $offset ); + } +} From 3e3b2b200696d9e5f51c29f86f8ec48a20df1bf4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:06:20 +0100 Subject: [PATCH 005/336] Starting to actually parse --- .../html-api/class-wp-css-selectors.php | 213 ++++++++++++++++-- 1 file changed, 199 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index acc5db02752c3..53417a0f1967c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -52,6 +52,11 @@ * */ class WP_CSS_Selectors { + private $selectors; + + private function __construct( array $selectors ) { + $this->selectors = $selectors; + } /** * Takes a CSS selectors string and returns an instance of itself or `null` if the selector @@ -60,11 +65,10 @@ class WP_CSS_Selectors { * @since TBD * * @param string $selectors CSS selectors string. - * @return static|null + * @return self|null */ - public static function from_selectors( string $selectors ) { - $res = new static(); - return $res; + public static function from_selectors( string $selectors ): ?self { + return self::parse( $selectors ); } /** @@ -72,7 +76,7 @@ public static function from_selectors( string $selectors ) { * * @since TBD * - * @return WP_CSS_Selector[] + * @return WP_CSS_Selectors|null */ private static function parse( string $input ) { // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. @@ -95,28 +99,209 @@ private static function parse( string $input ) { $input = str_replace( array( "\r", "\f" ), "\n", $input ); $input = str_replace( "\0", "\u{FFFD}", $input ); - $at = 0; $length = strlen( $input ); $selectors = array(); - $at = strspn( $input, "\n\t ", $at ); - while ( $at < $length ) { + $offset = 0; + + while ( $offset < $length ) { + $sel = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( $sel ) { + $selectors[] = $sel; + } + } + if ( count( $selectors ) ) { + return new WP_CSS_Selectors( $selectors ); + } + return null; + } +} + +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, string &$offset ): ?self { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; } + return new self( $ident ); } } interface IWP_CSS_Selector_Parser { - public static function parse( string $input, string $offset, ?int $consumed_bytes = null ): ?self; + /** + * @return static|null + */ + public static function parse( string $input, string &$offset ); } abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { - public static function parse_whitespace( string $input, string &$offset ): bool { + const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; + + protected static function parse_whitespace( string $input, string &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; return $advanced; } + /** + * Tokenization of hash tokens + * + * > U+0023 NUMBER SIGN (#) + * > If the next input code point is an ident code point or the next two input code points are a valid escape, then: + * > 1. Create a . + * > 2. If the next 3 input code points would start an ident sequence, set the + * > ’s type flag to "id". + * > 3. Consume an ident sequence, and set the ’s value to the + * > returned string. + * > 4. Return the . + * > Otherwise, return a with its value set to the current input code point. + * + * This implementation is not interested in the , a '#' delim token is not relevant for selectors. + */ + protected static function parse_hash_token( string $input, string &$offset ): ?string { + if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { + return null; + } + + $offset_after_hash = $offset + 1; + if ( self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset_after_hash ) ) { + $offset = $offset_after_hash; + return self::parse_ident( $input, $offset ); + } + return null; + } + + /** + * Parse an ident token + * + * CAUTION: This method is _not_ for parsing and ID selector! + * + * > 4.3.11. Consume an ident sequence + * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. + * > + * > Note: This algorithm does not do the verification of the first few code points that are necessary to ensure the returned code points would constitute an . If that is the intended use, ensure that the stream starts with an ident sequence before calling this algorithm. + * > + * > Let result initially be an empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ident code point + * > Append the code point to result. + * > the stream starts with a valid escape + * > Consume an escaped code point. Append the returned code point to result. + * > anything else + * > Reconsume the current input code point. Return result. + * + * https://www.w3.org/TR/css-syntax-3/#consume-name + */ + protected static function parse_ident( string $input, string &$offset ): ?string { + if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { + return null; + } + + $ident = ''; + + while ( $offset < strlen( $input ) ) { + if ( self::next_two_are_valid_escape( $input, $offset ) ) { + $ident .= self::consume_escaped_codepoint( $input, $offset ); + continue; + } elseif ( self::is_ident_codepoint( $input, $offset ) ) { + // @todo this should append and advance the correct number of bytes. + $ident .= $input[ $offset ]; + $offset += 1; + continue; + } + break; + } + + return $ident; + } + + /** + * Consume an escaped code point. + * + * > 4.3.7. Consume an escaped code point + * > This section describes how to consume an escaped code point. It assumes that the U+005C + * > REVERSE SOLIDUS (\) has already been consumed and that the next input code point has + * > already been verified to be part of a valid escape. It will return a code point. + * > + * > Consume the next input code point. + * > + * > hex digit + * > Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 + * > hex digits have been consumed in total. If the next input code point is whitespace, + * > consume it as well. Interpret the hex digits as a hexadecimal number. If this number is + * > zero, or is for a surrogate, or is greater than the maximum allowed code point, return + * > U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point with that value. + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + * > anything else + * > Return the current input code point. + */ + protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + $char = $input[ $offset ]; + if ( + ( '0' <= $char && $char <= '9' ) || + ( 'a' <= $char && $char <= 'f' ) || + ( 'A' <= $char && $char <= 'F' ) + ) { + $hex_end_offset = $offset + 1; + while ( + strlen( $input ) > $hex_end_offset && + $hex_end_offset - $offset < 6 && + ( + ( '0' <= $char && $char <= '9' ) || + ( 'a' <= $char && $char <= 'f' ) || + ( 'A' <= $char && $char <= 'F' ) + ) + ) { + $hex_end_offset += 1; + } + + $codepoint_value = hexdec( substr( $input, $offset, $hex_end_offset - $offset ) ); + + // > A surrogate is a leading surrogate or a trailing surrogate. + // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + // > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + // The surrogate ranges are adjacent, so the complete range is 0xD800..=0xDFFF, + // inclusive. + $codepoint_char = ( + 0 === $codepoint_value || + $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || + ( 0xD800 <= $codepoint_value || $codepoint_value <= 0xDFFF ) + ) ? + "\u{FFFD}" : + mb_chr( $codepoint_value, 'UTF-8' ); + + $offset = $hex_end_offset; + + // If the next input code point is whitespace, consume it as well. + if ( + strlen( $input ) > $offset && + ( + "\n" === $input[ $offset ] || + "\t" === $input[ $offset ] || + ' ' === $input[ $offset ] + ) + ) { + ++$offset; + } + return $codepoint_char; + } + + $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); + $offset += strlen( $codepoint_char ); + return $codepoint_char; + } + /* * Utiltities * ========== @@ -140,7 +325,7 @@ public static function parse_whitespace( string $input, string &$offset ): bool * * @todo this does not check whether the second codepoint is valid. */ - public static function next_two_are_valid_escape( string $input, string $offset ): bool { + protected static function next_two_are_valid_escape( string $input, string $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -159,7 +344,7 @@ public static function next_two_are_valid_escape( string $input, string $offset * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . */ - public static function is_ident_start_codepoint( string $input, string $offset ): bool { + protected static function is_ident_start_codepoint( string $input, string $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -178,7 +363,7 @@ public static function is_ident_start_codepoint( string $input, string $offset ) * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. */ - public static function is_ident_codepoint( string $input, string $offset ): bool { + protected static function is_ident_codepoint( string $input, string $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); @@ -203,7 +388,7 @@ public static function is_ident_codepoint( string $input, string $offset ): bool * * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier */ - public static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } From 967557fb01f0e016d63fa2b391d351aec90090bc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:41:16 +0100 Subject: [PATCH 006/336] Add ident tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/phpunit/tests/html-api/wpCssSelectors.php diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php new file mode 100644 index 0000000000000..2857603360e79 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -0,0 +1,50 @@ +assertSame( $ident, $result ); + $this->assertSame( substr( $input, $offset ), $rest ); + } +} From 2ec1db32af13f1248935ee5e8bb2d634430afc31 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:41:42 +0100 Subject: [PATCH 007/336] Fix ident non-ascii bug --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 53417a0f1967c..547a51293bb11 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -353,7 +353,7 @@ protected static function is_ident_start_codepoint( string $input, string $offse '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || - $input[ $offset ] <= '\x7F' + $input[ $offset ] > '\x7F' ); } From ee2c7cefa987ef4cb208447aad489a700ab7f91f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:42:12 +0100 Subject: [PATCH 008/336] Use class after defined --- .../html-api/class-wp-css-selectors.php | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 547a51293bb11..55396c8851294 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -117,23 +117,6 @@ private static function parse( string $input ) { } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { - /** @var string */ - public $ident; - - private function __construct( string $ident ) { - $this->ident = $ident; - } - - public static function parse( string $input, string &$offset ): ?self { - $ident = self::parse_hash_token( $input, $offset ); - if ( null === $ident ) { - return null; - } - return new self( $ident ); - } -} - interface IWP_CSS_Selector_Parser { /** * @return static|null @@ -431,3 +414,20 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen return self::is_ident_start_codepoint( $input, $offset ); } } + +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, string &$offset ): ?self { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new self( $ident ); + } +} From 0f708ba4892a50249d0c2267640acf2a256beb21 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 18:01:07 +0100 Subject: [PATCH 009/336] Fix some char stuff --- .../html-api/class-wp-css-selectors.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 55396c8851294..408d25395febb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -194,6 +194,8 @@ protected static function parse_ident( string $input, string &$offset ): ?string while ( $offset < strlen( $input ) ) { if ( self::next_two_are_valid_escape( $input, $offset ) ) { + // Move past the `\` character. + ++$offset; $ident .= self::consume_escaped_codepoint( $input, $offset ); continue; } elseif ( self::is_ident_codepoint( $input, $offset ) ) { @@ -230,20 +232,19 @@ protected static function parse_ident( string $input, string &$offset ): ?string * > Return the current input code point. */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { - $char = $input[ $offset ]; if ( - ( '0' <= $char && $char <= '9' ) || - ( 'a' <= $char && $char <= 'f' ) || - ( 'A' <= $char && $char <= 'F' ) + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'f' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'F' ) ) { $hex_end_offset = $offset + 1; while ( strlen( $input ) > $hex_end_offset && $hex_end_offset - $offset < 6 && ( - ( '0' <= $char && $char <= '9' ) || - ( 'a' <= $char && $char <= 'f' ) || - ( 'A' <= $char && $char <= 'F' ) + ( '0' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= '9' ) || + ( 'a' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'f' ) || + ( 'A' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'F' ) ) ) { $hex_end_offset += 1; @@ -259,7 +260,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string $codepoint_char = ( 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || - ( 0xD800 <= $codepoint_value || $codepoint_value <= 0xDFFF ) + ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) ) ? "\u{FFFD}" : mb_chr( $codepoint_value, 'UTF-8' ); From 3cb455d41f7923d4b4be9fec3b7cf3f72686dfdc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 18:01:17 +0100 Subject: [PATCH 010/336] Improve tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 2857603360e79..a55463ec7122e 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -15,19 +15,20 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { public static function data_valid_idents() { return array( - array( '_-foo123#xyz', '_-foo123', '#xyz' ), - array( '😍foo123.xyz', '😍foo123', '.xyz' ), - array( '\\xyz', 'xyz', '' ), - array( '\\ x', ' x', '' ), - array( '\\😍', '😍', '' ), - array( '\\abcd', 'ꯍ', '' ), + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), - array( "\\31\t23", '123', '' ), - array( "\\31\n23", '123', '' ), - array( "\\31 23", '123', '' ), - array( '\\9', "\t", '' ), - array( '\\61 bc', 'abc', '' ), - array( '\\000061bc', 'abc', '' ), + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), ); } @@ -44,7 +45,7 @@ public static function test( string $input, &$offset ) { $offset = 0; $ident = $c::test( $input, $offset ); - $this->assertSame( $ident, $result ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $ident, $result, 'Ident did not match.' ); + $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); } } From 5609e509ef589afbe23654fe629ce85fc06ad7ec Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 19:53:10 +0100 Subject: [PATCH 011/336] Housekeeping --- src/wp-includes/html-api/class-wp-css-selectors.php | 4 +--- tests/phpunit/tests/html-api/wpCssSelectors.php | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 408d25395febb..f9c85f9b48a3c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1,14 +1,12 @@ array( '_-foo123#xyz', '_-foo123', '#xyz' ), @@ -33,6 +36,8 @@ public static function data_valid_idents() { } /** + * @ticket TBD + * * @dataProvider data_valid_idents */ public function test_valid_idents( string $input, string $result, string $rest ) { From 4f25bc21f907369c899ea2c8c07e7461bdb731e3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 19:56:30 +0100 Subject: [PATCH 012/336] Require new file in WP --- src/wp-settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-settings.php b/src/wp-settings.php index 635f6de248dd5..6c799d5c95140 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -265,6 +265,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-selectors.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.php'; From 943293f2f840988546c84d17d59dfe4d37e05448 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:14:21 +0100 Subject: [PATCH 013/336] Fix offset type --- .../html-api/class-wp-css-selectors.php | 18 +++++++++--------- .../phpunit/tests/html-api/wpCssSelectors.php | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index f9c85f9b48a3c..897cf4b59d752 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -119,13 +119,13 @@ interface IWP_CSS_Selector_Parser { /** * @return static|null */ - public static function parse( string $input, string &$offset ); + public static function parse( string $input, int &$offset ); } abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; - protected static function parse_whitespace( string $input, string &$offset ): bool { + protected static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; @@ -147,7 +147,7 @@ protected static function parse_whitespace( string $input, string &$offset ): bo * * This implementation is not interested in the , a '#' delim token is not relevant for selectors. */ - protected static function parse_hash_token( string $input, string &$offset ): ?string { + protected static function parse_hash_token( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { return null; } @@ -183,7 +183,7 @@ protected static function parse_hash_token( string $input, string &$offset ): ?s * * https://www.w3.org/TR/css-syntax-3/#consume-name */ - protected static function parse_ident( string $input, string &$offset ): ?string { + protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { return null; } @@ -307,7 +307,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string * * @todo this does not check whether the second codepoint is valid. */ - protected static function next_two_are_valid_escape( string $input, string $offset ): bool { + protected static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -326,7 +326,7 @@ protected static function next_two_are_valid_escape( string $input, string $offs * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . */ - protected static function is_ident_start_codepoint( string $input, string $offset ): bool { + protected static function is_ident_start_codepoint( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -345,7 +345,7 @@ protected static function is_ident_start_codepoint( string $input, string $offse * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. */ - protected static function is_ident_codepoint( string $input, string $offset ): bool { + protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); @@ -370,7 +370,7 @@ protected static function is_ident_codepoint( string $input, string $offset ): b * * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier */ - protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -422,7 +422,7 @@ private function __construct( string $ident ) { $this->ident = $ident; } - public static function parse( string $input, string &$offset ): ?self { + public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); if ( null === $ident ) { return null; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 39d68efcd8f4a..e0dd09c929d09 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -42,7 +42,7 @@ public static function data_valid_idents() { */ public function test_valid_idents( string $input, string $result, string $rest ) { $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, string &$offset ) {} + public static function parse( string $input, int &$offset ) {} public static function test( string $input, &$offset ) { return self::parse_ident( $input, $offset ); } From 24c9744657023179a33f786a6a7b4d0242534783 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:14:48 +0100 Subject: [PATCH 014/336] Add more tests and invalid tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index e0dd09c929d09..d12fcc42c8e60 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -18,20 +18,41 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { */ public static function data_valid_idents() { return array( - 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), - 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), - 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), - 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), - 'escaped space' => array( '\\ x', ' x', '' ), - 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), - 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), - 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), - 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), - 'hex tab' => array( '\\9', "\t", '' ), - 'hex a' => array( '\\61 bc', 'abc', '' ), - 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + + 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), + 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), + 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), + 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), + 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), + 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + ); + } + + /** + * Data provider. + */ + public static function data_invalid_idents() { + return array( + 'bad start >' => array( '>' ), + 'bad start [' => array( '[' ), + 'bad start #' => array( '#' ), + 'bad start " "' => array( ' ' ), + 'bad start -' => array( '-' ), + 'bad start 1' => array( '-' ), ); } @@ -53,4 +74,23 @@ public static function test( string $input, &$offset ) { $this->assertSame( $ident, $result, 'Ident did not match.' ); $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); } + + /** + * @ticket TBD + * + * @dataProvider data_invalid_idents + */ + public function test_invalid_idents( string $input ) { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + public static function test( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + }; + + $offset = 0; + $result = $c::test( $input, $offset ); + $this->assertNull( $result, 'Ident did not match.' ); + $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); + } } From a7c10b9e12aeed9263a69b46eeb011e59092ed07 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:15:03 +0100 Subject: [PATCH 015/336] Fix wrong offset var usage --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 897cf4b59d752..8afb3928e07de 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -383,7 +383,7 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen // > U+002D HYPHEN-MINUS if ( '-' === $input[ $offset ] ) { $after_initial_hyphen_minus_offset = $offset + 1; - if ( $offset >= strlen( $input ) ) { + if ( $after_initial_hyphen_minus_offset >= strlen( $input ) ) { return false; } From dd718b7093dfa3510d6b7476b39510013f759797 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:17:15 +0100 Subject: [PATCH 016/336] comment tweak --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8afb3928e07de..64020bcc0c607 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -392,7 +392,7 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen return true; } - // > If the second and third code points are a valid escape, return true. + // > If the second and third code points are a valid escape… return true. if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { return true; } From 5884aca6e807002d6474c37e291b3dde5c59778d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:53:50 +0100 Subject: [PATCH 017/336] Implement codepoint escape with strspn --- .../html-api/class-wp-css-selectors.php | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 64020bcc0c607..56c31911d95b8 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -230,25 +230,9 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * > Return the current input code point. */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { - if ( - ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || - ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'f' ) || - ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'F' ) - ) { - $hex_end_offset = $offset + 1; - while ( - strlen( $input ) > $hex_end_offset && - $hex_end_offset - $offset < 6 && - ( - ( '0' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= '9' ) || - ( 'a' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'f' ) || - ( 'A' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'F' ) - ) - ) { - $hex_end_offset += 1; - } - - $codepoint_value = hexdec( substr( $input, $offset, $hex_end_offset - $offset ) ); + $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); + if ( $hex_length > 0 ) { + $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); // > A surrogate is a leading surrogate or a trailing surrogate. // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. @@ -263,7 +247,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string "\u{FFFD}" : mb_chr( $codepoint_value, 'UTF-8' ); - $offset = $hex_end_offset; + $offset += $hex_length; // If the next input code point is whitespace, consume it as well. if ( From a9a077f463c9c981adc811b7be6b27d89c05d9dc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:54:11 +0100 Subject: [PATCH 018/336] Test with UPPER HEX --- tests/phpunit/tests/html-api/wpCssSelectors.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index d12fcc42c8e60..270def39b53d3 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -24,7 +24,8 @@ public static function data_valid_idents() { 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), 'escaped space' => array( '\\ x', ' x', '' ), 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), + 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), + 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), From 5f53e0a50b472a0aff078f233d6d7ffae189de33 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:34:25 +0100 Subject: [PATCH 019/336] Add ID tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 270def39b53d3..149bcd1f9572d 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -94,4 +94,33 @@ public static function test( string $input, int &$offset ) { $this->assertNull( $result, 'Ident did not match.' ); $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); } + + /** + * @ticket TBD + * + * @dataProvider data_ids + */ + public function test_parse_id( string $input, ?string $expected_id = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( null === $expected_id ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected_id ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + public static function data_ids(): array { + return array( + 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), + 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), + 'escaped #\31 23' => array( '#\\31 23', '123', '' ), + 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), + + 'not ID foo' => array( 'foo' ), + 'not valid #1foo' => array( '#1foo' ), + 'not id .bar' => array( '.bar' ), + ); + } } From effbbbece335486d269ecccf480fab99fc497d17 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:46:07 +0100 Subject: [PATCH 020/336] Improve tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 149bcd1f9572d..53495f0b09004 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -15,8 +15,10 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { /** * Data provider. + * + * @return array */ - public static function data_valid_idents() { + public static function data_idents(): array { return array( 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), @@ -40,29 +42,23 @@ public static function data_valid_idents() { 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), - ); - } - /** - * Data provider. - */ - public static function data_invalid_idents() { - return array( - 'bad start >' => array( '>' ), - 'bad start [' => array( '[' ), - 'bad start #' => array( '#' ), - 'bad start " "' => array( ' ' ), - 'bad start -' => array( '-' ), - 'bad start 1' => array( '-' ), + // Invalid + 'bad start >' => array( '>' ), + 'bad start [' => array( '[' ), + 'bad start #' => array( '#' ), + 'bad start " "' => array( ' ' ), + 'bad start -' => array( '-' ), + 'bad start 1' => array( '-' ), ); } /** * @ticket TBD * - * @dataProvider data_valid_idents + * @dataProvider data_idents */ - public function test_valid_idents( string $input, string $result, string $rest ) { + public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ) {} public static function test( string $input, &$offset ) { @@ -70,48 +66,38 @@ public static function test( string $input, &$offset ) { } }; - $offset = 0; - $ident = $c::test( $input, $offset ); - $this->assertSame( $ident, $result, 'Ident did not match.' ); - $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); - } - - /** - * @ticket TBD - * - * @dataProvider data_invalid_idents - */ - public function test_invalid_idents( string $input ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, int &$offset ) { - return self::parse_ident( $input, $offset ); - } - }; - $offset = 0; $result = $c::test( $input, $offset ); - $this->assertNull( $result, 'Ident did not match.' ); - $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'Ident did not match.' ); + $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); + } } /** * @ticket TBD * - * @dataProvider data_ids + * @dataProvider data_id_selectors */ - public function test_parse_id( string $input, ?string $expected_id = null, ?string $rest = null ) { + public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; $result = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( null === $expected_id ) { + if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected_id ); + $this->assertSame( $result->ident, $expected ); $this->assertSame( substr( $input, $offset ), $rest ); } } - public static function data_ids(): array { + /** + * Data provider. + * + * @return array + */ + public static function data_id_selectors(): array { return array( 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), @@ -119,8 +105,8 @@ public static function data_ids(): array { 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), 'not ID foo' => array( 'foo' ), + 'not ID .bar' => array( '.bar' ), 'not valid #1foo' => array( '#1foo' ), - 'not id .bar' => array( '.bar' ), ); } } From 62ec5bb804872afe38073e86a0e23ee1d5cd16a7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:46:23 +0100 Subject: [PATCH 021/336] Add class selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 53495f0b09004..aac3339e4d27d 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -109,4 +109,38 @@ public static function data_id_selectors(): array { 'not valid #1foo' => array( '#1foo' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_class_selectors + */ + public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_Class_Selector::parse( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_class_selectors(): array { + return array( + 'valid ._-foo123' => array( '._-foo123', '_-foo123', '' ), + 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), + 'escaped .\31 23' => array( '.\\31 23', '123', '' ), + 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), + + 'not class foo' => array( 'foo' ), + 'not class #bar' => array( '#bar' ), + 'not valid .1foo' => array( '.1foo' ), + ); + } } From 153f00978429f98cd7c5cc3d65a8b8affdcf1e45 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:47:00 +0100 Subject: [PATCH 022/336] Add class selector --- .../html-api/class-wp-css-selectors.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 56c31911d95b8..7b72fa0fe9616 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -414,3 +414,29 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $ident ); } } + +final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, int &$offset ): ?self { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + $offset = $updated_offset; + } + + $offset = $updated_offset; + return new self( $result ); + } +} From fcc6401475554cd955891ae1dd82e067064067e8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:47:21 +0100 Subject: [PATCH 023/336] Simplify id selector parse --- .../html-api/class-wp-css-selectors.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 7b72fa0fe9616..fbccb55a5a0eb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -152,12 +152,16 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri return null; } - $offset_after_hash = $offset + 1; - if ( self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset_after_hash ) ) { - $offset = $offset_after_hash; - return self::parse_ident( $input, $offset ); + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + $offset = $updated_offset; } - return null; + + $offset = $updated_offset; + return $result; } /** From 21c67e52745b532489f6a494892b71c83f1b03ac Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:02:03 +0100 Subject: [PATCH 024/336] Improve ident tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index aac3339e4d27d..b3099146e226c 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -42,14 +42,20 @@ public static function data_idents(): array { 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + 'can start with -ident' => array( '-ident', '-ident', '' ), + 'can start with --anything' => array( '--anything', '--anything', '' ), + 'can start with ---anything' => array( '--_anything', '--_anything', '' ), + 'can start with --1anything' => array( '--1anything', '--1anything', '' ), + 'can start with -\31 23' => array( '-\31 23', '-123', '' ), + 'can start with --\31 23' => array( '--\31 23', '--123', '' ), // Invalid - 'bad start >' => array( '>' ), - 'bad start [' => array( '[' ), - 'bad start #' => array( '#' ), - 'bad start " "' => array( ' ' ), - 'bad start -' => array( '-' ), - 'bad start 1' => array( '-' ), + 'bad start >' => array( '>ident' ), + 'bad start [' => array( '[ident' ), + 'bad start #' => array( '#ident' ), + 'bad start " "' => array( ' ident' ), + 'bad start 1' => array( '1ident' ), + 'bad start -1' => array( '-1ident' ), ); } From 728d798d663d27f5b385d82fe54f3b88544983de Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:31:24 +0100 Subject: [PATCH 025/336] Add type selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index b3099146e226c..694c405c09e0b 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -149,4 +149,39 @@ public static function data_class_selectors(): array { 'not valid .1foo' => array( '.1foo' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_type_selectors + */ + public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_Type_Selector::parse( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_type_selectors(): array { + return array( + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + + // invalid + '#id' => array( '#id' ), + '.class' => array( '.class' ), + '[attr]' => array( '[attr]' ), + ); + } } From e1e8e098cfa4d0854104760e7e225e265f022064 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:31:54 +0100 Subject: [PATCH 026/336] Add docs and remove unreachable line --- .../html-api/class-wp-css-selectors.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index fbccb55a5a0eb..4ea438b95d8ce 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -410,6 +410,13 @@ private function __construct( string $ident ) { $this->ident = $ident; } + /** + * Parse an ID selector + * + * > = + * + * https://www.w3.org/TR/selectors/#grammar + */ public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); if ( null === $ident ) { @@ -427,6 +434,13 @@ private function __construct( string $ident ) { $this->ident = $ident; } + /** + * Parse a class selector + * + * > = '.' + * + * https://www.w3.org/TR/selectors/#grammar + */ public static function parse( string $input, int &$offset ): ?self { if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { return null; @@ -437,7 +451,6 @@ public static function parse( string $input, int &$offset ): ?self { if ( null === $result ) { return null; - $offset = $updated_offset; } $offset = $updated_offset; From 13ac3c11204d31e30455870bff92f0b81ecd3386 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:32:17 +0100 Subject: [PATCH 027/336] Add type selector class --- .../html-api/class-wp-css-selectors.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 4ea438b95d8ce..4a6b65048b62b 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -457,3 +457,46 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $result ); } } + +final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { + /** + * @var string + * + * The type identifier string or '*'. + */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + /** + * Parse a type selector + * + * > = | ? '*' + * > = [ | '*' ]? '|' + * > = ? + * + * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, + * so this selector effectively matches * or ident. + * + * https://www.w3.org/TR/selectors/#grammar + */ + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return false; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new self( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new self( $result ); + } +} From a3c25e892f059f02d42070d593d03c5199a15e8d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:13:39 +0100 Subject: [PATCH 028/336] Add attribute selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 694c405c09e0b..5d0af28006039 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -184,4 +184,69 @@ public static function data_type_selectors(): array { '[attr]' => array( '[attr]' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_attribute_selectors + */ + public function test_parse_attribute( + string $input, + ?string $expected_name = null, + ?string $expected_matcher = null, + ?string $expected_value = null, + ?string $expected_modifier = null, + ?string $rest = null + ) { + $offset = 0; + $result = WP_CSS_Attribute_Selector::parse( $input, $offset ); + if ( null === $expected_name ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->name, $expected_name ); + $this->assertSame( $result->matcher, $expected_matcher ); + $this->assertSame( $result->value, $expected_value ); + $this->assertSame( $result->modifier, $expected_modifier ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_attribute_selectors(): array { + return array( + array( '[href]', 'href', null, null, null, '' ), + array( '[href] type', 'href', null, null, null, ' type' ), + array( '[href]#id', 'href', null, null, null, '#id' ), + array( '[href].class', 'href', null, null, null, '.class' ), + array( '[href][href2]', 'href', null, null, null, '[href2]' ), + array( "[\n href\t\r]", 'href', null, null, null, '' ), + array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), + array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + // Invalid + array( 'foo' ), + array( '[foo' ), + array( '[#foo]' ), + array( '[*|*]' ), + array( '[ns|*]' ), + array( '[* |att]' ), + array( '[*| att]' ), + array( '[att * =]' ), + array( '[att * =]' ), + array( '[att i]' ), + array( '[att s]' ), + array( '[att="val" I]' ), + array( '[att="val" S]' ), + ); + } } From ad5c600d99ffeb98e92e6678b1476c0a7e02a808 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:49:57 +0100 Subject: [PATCH 029/336] improve attr tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 5d0af28006039..43c710a6f750c 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -218,35 +218,35 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - array( '[href]', 'href', null, null, null, '' ), - array( '[href] type', 'href', null, null, null, ' type' ), - array( '[href]#id', 'href', null, null, null, '#id' ), - array( '[href].class', 'href', null, null, null, '.class' ), - array( '[href][href2]', 'href', null, null, null, '[href2]' ), - array( "[\n href\t\r]", 'href', null, null, null, '' ), - array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), - array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match="quoted[][]"]' => array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + "[match='quoted!{}']" => array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), // Invalid - array( 'foo' ), - array( '[foo' ), - array( '[#foo]' ), - array( '[*|*]' ), - array( '[ns|*]' ), - array( '[* |att]' ), - array( '[*| att]' ), - array( '[att * =]' ), - array( '[att * =]' ), - array( '[att i]' ), - array( '[att s]' ), - array( '[att="val" I]' ), - array( '[att="val" S]' ), + 'foo' => array( 'foo' ), + '[foo' => array( '[foo' ), + '[#foo]' => array( '[#foo]' ), + '[*|*]' => array( '[*|*]' ), + '[ns|*]' => array( '[ns|*]' ), + '[* |att]' => array( '[* |att]' ), + '[*| att]' => array( '[*| att]' ), + '[att * =]' => array( '[att * =]' ), + '[att * =]' => array( '[att * =]' ), + '[att i]' => array( '[att i]' ), + '[att s]' => array( '[att s]' ), + '[att="val" I]' => array( '[att="val" I]' ), + '[att="val" S]' => array( '[att="val" S]' ), ); } } From 675870497312b388d4992090c7681886b06c919a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:53:06 +0100 Subject: [PATCH 030/336] Fix expectation argument order --- .../phpunit/tests/html-api/wpCssSelectors.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 43c710a6f750c..7bea7c3b34180 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -78,7 +78,7 @@ public static function test( string $input, &$offset ) { $this->assertNull( $result ); } else { $this->assertSame( $expected, $result, 'Ident did not match.' ); - $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); } } @@ -93,8 +93,8 @@ public function test_parse_id( string $input, ?string $expected = null, ?string if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -127,8 +127,8 @@ public function test_parse_class( string $input, ?string $expected = null, ?stri if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -161,8 +161,8 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -203,11 +203,11 @@ public function test_parse_attribute( if ( null === $expected_name ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->name, $expected_name ); - $this->assertSame( $result->matcher, $expected_matcher ); - $this->assertSame( $result->value, $expected_value ); - $this->assertSame( $result->modifier, $expected_modifier ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected_name, $result->name ); + $this->assertSame( $expected_matcher, $result->matcher ); + $this->assertSame( $expected_value, $result->value ); + $this->assertSame( $expected_modifier, $result->modifier ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } From e97842cf6665fef97059b71acef61e70ebbdf03e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 21:31:31 +0100 Subject: [PATCH 031/336] Add test and fix is_ident --- .../html-api/class-wp-css-selectors.php | 2 +- .../phpunit/tests/html-api/wpCssSelectors.php | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 4a6b65048b62b..49b51e51fe81e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -323,7 +323,7 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || - $input[ $offset ] > '\x7F' + ord( $input[ $offset ] ) > 0x7F ); } diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7bea7c3b34180..55cd1eafb29c9 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -48,6 +48,7 @@ public static function data_idents(): array { 'can start with --1anything' => array( '--1anything', '--1anything', '' ), 'can start with -\31 23' => array( '-\31 23', '-123', '' ), 'can start with --\31 23' => array( '--\31 23', '--123', '' ), + 'ident ends before ]' => array( 'ident]', 'ident', ']' ), // Invalid 'bad start >' => array( '>ident' ), @@ -59,6 +60,28 @@ public static function data_idents(): array { ); } + /** + * @ticket TBD + */ + public function test_is_ident_and_is_ident_start() { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + + public static function test_is_ident( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + + $this->assertFalse( $c::test_is_ident( '[', 0 ) ); + $this->assertFalse( $c::test_is_ident( ']', 0 ) ); + $this->assertFalse( $c::test_is_ident_start( '[', 0 ) ); + $this->assertFalse( $c::test_is_ident_start( ']', 0 ) ); + } + /** * @ticket TBD * From ef0085631424083dfc217308684c1baac3eea7f8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:36:40 +0100 Subject: [PATCH 032/336] Add parse_string stub --- src/wp-includes/html-api/class-wp-css-selectors.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 49b51e51fe81e..96c4465c2dbd6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -212,6 +212,11 @@ protected static function parse_ident( string $input, int &$offset ): ?string { return $ident; } + // @todo stub + protected static function parse_string( string $input, int &$offset ): ?string { + return null; + } + /** * Consume an escaped code point. * From 463e799a75d713829f84a988d58595d2ba0923f0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:37:31 +0100 Subject: [PATCH 033/336] Add attribute selector parsing --- .../html-api/class-wp-css-selectors.php | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 96c4465c2dbd6..5067d1c2b87e6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -505,3 +505,216 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $result ); } } + +final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { + /** + * [attr=value] + * Represents elements with an attribute name of attr whose value is exactly value. + */ + const MATCH_EXACT = 'MATCH_EXACT'; + + /** + * [attr~=value] + * Represents elements with an attribute name of attr whose value is a + * whitespace-separated list of words, one of which is exactly value. + */ + const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; + + /** + * [attr|=value] + * Represents elements with an attribute name of attr whose value can be exactly value or + * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for + * language subcode matches. + */ + const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; + + /** + * [attr^=value] + * Represents elements with an attribute name of attr whose value is prefixed (preceded) + * by value. + */ + const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; + + /** + * [attr$=value] + * Represents elements with an attribute name of attr whose value is suffixed (followed) + * by value. + */ + const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; + + /** + * [attr*=value] + * Represents elements with an attribute name of attr whose value contains at least one + * occurrence of value within the string. + */ + const MATCH_CONTAINS = 'MATCH_CONTAINS'; + + /** + * Modifier for case sensitive matching + * [attr=value s] + */ + const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; + + /** + * Modifier for case insensitive matching + * [attr=value i] + */ + const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; + + + /** + * The attribute name. + * + * @var string + */ + public $name; + + /** + * The attribute matcher. + * + * @var string|null + */ + public $matcher; + + /** + * The attribute value. + * + * @var string|null + */ + public $value; + + /** + * The attribute modifier. + * + * @var string|null + */ + public $modifier; + + private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + $this->name = $name; + $this->matcher = $matcher; + $this->value = $value; + $this->modifier = $modifier; + } + + /** + * Parse a attribute selector + * + * > = '[' ']' | + * > '[' [ | ] ? ']' + * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * > = i | s + * > = ? + * + * Namespaces are not supported, so attribute names are effectively identifiers. + * + * https://www.w3.org/TR/selectors/#grammar + */ + public static function parse( string $input, int &$offset ): ?self { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return false; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new self( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } +} From 0f5b28cc5ed226f23ea38a3025ae5403b9b24bff Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:45:17 +0100 Subject: [PATCH 034/336] Fix test expectations --- tests/phpunit/tests/html-api/wpCssSelectors.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 55cd1eafb29c9..ae3c3e80c4f90 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -248,13 +248,13 @@ public static function data_attribute_selectors(): array { '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), - '[match $= insensitive i]' => array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[match="quoted[][]"]' => array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - "[match='quoted!{}']" => array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match="quoted[][]"]' => array( '[match="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + "[match='quoted!{}']" => array( "[match='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), // Invalid 'foo' => array( 'foo' ), From f4a491ae52aaaf4807e9eb9c9b6c671bae105abf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:11:25 +0100 Subject: [PATCH 035/336] More and improved attribute tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index ae3c3e80c4f90..4557ee1a5b3c4 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -252,24 +252,28 @@ public static function data_attribute_selectors(): array { '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[match="quoted[][]"]' => array( '[match="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - "[match='quoted!{}']" => array( "[match='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid - 'foo' => array( 'foo' ), - '[foo' => array( '[foo' ), - '[#foo]' => array( '[#foo]' ), - '[*|*]' => array( '[*|*]' ), - '[ns|*]' => array( '[ns|*]' ), - '[* |att]' => array( '[* |att]' ), - '[*| att]' => array( '[*| att]' ), - '[att * =]' => array( '[att * =]' ), - '[att * =]' => array( '[att * =]' ), - '[att i]' => array( '[att i]' ), - '[att s]' => array( '[att s]' ), - '[att="val" I]' => array( '[att="val" I]' ), - '[att="val" S]' => array( '[att="val" S]' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + 'Invalid: [att="val" I]' => array( '[att="val" I]' ), + 'Invalid: [att="val" S]' => array( '[att="val" S]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), ); } } From b680b1b8e5f69bf17490934761899452fc935826 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:11:52 +0100 Subject: [PATCH 036/336] Implement parse_string --- .../html-api/class-wp-css-selectors.php | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 5067d1c2b87e6..c1c3e35fc9ae1 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -212,9 +212,89 @@ protected static function parse_ident( string $input, int &$offset ): ?string { return $ident; } - // @todo stub + /** + * Parse a string token + * + * > 4.3.5. Consume a string token + * > This section describes how to consume a string token from a stream of code points. It returns either a or . + * > + * > This algorithm may be called with an ending code point, which denotes the code point that ends the string. If an ending code point is not specified, the current input code point is used. + * > + * > Initially create a with its value set to the empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ending code point + * > Return the . + * > EOF + * > This is a parse error. Return the . + * > newline + * > This is a parse error. Reconsume the current input code point, create a , and return it. + * > U+005C REVERSE SOLIDUS (\) + * > If the next input code point is EOF, do nothing. + * > Otherwise, if the next input code point is a newline, consume it. + * > Otherwise, (the stream starts with a valid escape) consume an escaped code point and append the returned code point to the ’s value. + * > + * > anything else + * > Append the current input code point to the ’s value. + * + * https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * This implementation will never return a because + * the is not a part of the selector grammar. That + * case is treated as failure to parse and null is returned. + */ protected static function parse_string( string $input, int &$offset ): ?string { - return null; + if ( $offset + 1 >= strlen( $input ) ) { + return null; + } + + $ending_code_point = $input[ $offset ]; + if ( '"' !== $ending_code_point && "'" !== $ending_code_point ) { + return null; + } + + $string_token = ''; + + $stop_characters = "\\\n{$ending_code_point}"; + + $updated_offset = $offset + 1; + while ( $updated_offset < strlen( $input ) ) { + switch ( $input[ $updated_offset ] ) { + case '\\': + if ( $updated_offset + 1 >= strlen( $input ) ) { + break; + } + ++$updated_offset; + if ( "\n" === $input[ $updated_offset ] ) { + ++$updated_offset; + break; + } else { + $string_token .= self::consume_escaped_codepoint( $input, $updated_offset ); + } + break; + + /* + * This case would return a . + * The is not a part of the selector grammar + * so we do not return it and instead treat this as a + * failure to parse a string token. + */ + case "\n": + return null; + + case $ending_code_point: + ++$updated_offset; + break 2; + + default: + $string_token .= $input[ $updated_offset ]; + ++$updated_offset; + } + } + + $offset = $updated_offset; + return $string_token; } /** From e7da05f238008dd987f176672565acfeacbd86b4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:25:20 +0100 Subject: [PATCH 037/336] Add string parse tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 4557ee1a5b3c4..96f2fa96dcb7f 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -9,8 +9,6 @@ * @since TBD * * @group html-api - * - * @coversDefaultClass WP_CSS_Selectors */ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { /** @@ -62,6 +60,9 @@ public static function data_idents(): array { /** * @ticket TBD + * + * @covers WP_CSS_Selector_Parser::is_ident_codepoint + * @covers WP_CSS_Selector_Parser::is_ident_start_codepoint */ public function test_is_ident_and_is_ident_start() { $c = new class() extends WP_CSS_Selector_Parser { @@ -86,6 +87,8 @@ public static function test_is_ident_start( string $input, int $offset ) { * @ticket TBD * * @dataProvider data_idents + * + * @covers WP_CSS_Selector_Parser::parse_ident */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -105,10 +108,69 @@ public static function test( string $input, &$offset ) { } } + /** + * @ticket TBD + * + * @dataProvider data_strings + * + * @covers WP_CSS_Selector_Parser::parse_string + */ + public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + public static function test( string $input, &$offset ) { + return self::parse_string( $input, $offset ); + } + }; + + $offset = 0; + $result = $c::test( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'String did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_strings(): array { + return array( + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + // Invalid + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: \\"' => array( '\\"' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), + ); + } + /** * @ticket TBD * * @dataProvider data_id_selectors + * + * @covers WP_CSS_ID_Selector::parse */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -143,6 +205,8 @@ public static function data_id_selectors(): array { * @ticket TBD * * @dataProvider data_class_selectors + * + * @covers WP_CSS_Class_Selector::parse */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -177,6 +241,8 @@ public static function data_class_selectors(): array { * @ticket TBD * * @dataProvider data_type_selectors + * + * @covers WP_CSS_Type_Selector::parse */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -212,6 +278,8 @@ public static function data_type_selectors(): array { * @ticket TBD * * @dataProvider data_attribute_selectors + * + * @covers WP_CSS_Attribute_Selector::parse */ public function test_parse_attribute( string $input, From d5e7e6087aab9f58905aa3c5993a5357efe812e1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:26:01 +0100 Subject: [PATCH 038/336] Remove covers annotations --- tests/phpunit/tests/html-api/wpCssSelectors.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 96f2fa96dcb7f..7c5cdca447bbe 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -60,9 +60,6 @@ public static function data_idents(): array { /** * @ticket TBD - * - * @covers WP_CSS_Selector_Parser::is_ident_codepoint - * @covers WP_CSS_Selector_Parser::is_ident_start_codepoint */ public function test_is_ident_and_is_ident_start() { $c = new class() extends WP_CSS_Selector_Parser { @@ -87,8 +84,6 @@ public static function test_is_ident_start( string $input, int $offset ) { * @ticket TBD * * @dataProvider data_idents - * - * @covers WP_CSS_Selector_Parser::parse_ident */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -112,8 +107,6 @@ public static function test( string $input, &$offset ) { * @ticket TBD * * @dataProvider data_strings - * - * @covers WP_CSS_Selector_Parser::parse_string */ public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -169,8 +162,6 @@ public static function data_strings(): array { * @ticket TBD * * @dataProvider data_id_selectors - * - * @covers WP_CSS_ID_Selector::parse */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -205,8 +196,6 @@ public static function data_id_selectors(): array { * @ticket TBD * * @dataProvider data_class_selectors - * - * @covers WP_CSS_Class_Selector::parse */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -241,8 +230,6 @@ public static function data_class_selectors(): array { * @ticket TBD * * @dataProvider data_type_selectors - * - * @covers WP_CSS_Type_Selector::parse */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -278,8 +265,6 @@ public static function data_type_selectors(): array { * @ticket TBD * * @dataProvider data_attribute_selectors - * - * @covers WP_CSS_Attribute_Selector::parse */ public function test_parse_attribute( string $input, From 08187c6858d95503d0e11eed6832045a68579f8a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:32:55 +0100 Subject: [PATCH 039/336] Remove unused line --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index c1c3e35fc9ae1..3a4c0a7577679 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -256,8 +256,6 @@ protected static function parse_string( string $input, int &$offset ): ?string { $string_token = ''; - $stop_characters = "\\\n{$ending_code_point}"; - $updated_offset = $offset + 1; while ( $updated_offset < strlen( $input ) ) { switch ( $input[ $updated_offset ] ) { From 5a5066ce52335b330a57441b765ed9cc33184467 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:32:21 +0100 Subject: [PATCH 040/336] Improve tests for 100% coverage on parse methods --- .../phpunit/tests/html-api/wpCssSelectors.php | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7c5cdca447bbe..7b6e5ce79a365 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -49,12 +49,14 @@ public static function data_idents(): array { 'ident ends before ]' => array( 'ident]', 'ident', ']' ), // Invalid - 'bad start >' => array( '>ident' ), - 'bad start [' => array( '[ident' ), - 'bad start #' => array( '#ident' ), - 'bad start " "' => array( ' ident' ), - 'bad start 1' => array( '1ident' ), - 'bad start -1' => array( '-1ident' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: bad start >' => array( '>ident' ), + 'Invalid: bad start [' => array( '[ident' ), + 'Invalid: bad start #' => array( '#ident' ), + 'Invalid: bad start " "' => array( ' ident' ), + 'Invalid: bad start 1' => array( '1ident' ), + 'Invalid: bad start -1' => array( '-1ident' ), + 'Invalid: bad start -' => array( '-' ), ); } @@ -133,28 +135,31 @@ public static function test( string $input, &$offset ) { */ public static function data_strings(): array { return array( - '"foo"' => array( '"foo"', 'foo', '' ), - '"foo"after' => array( '"foo"after', 'foo', 'after' ), - '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), - '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), - "'foo'" => array( "'foo'", 'foo', '' ), - "'foo'after" => array( "'foo'after", 'foo', 'after' ), - "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), - "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), - "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), - "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), - "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), - "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), - "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + "'foo\\" => array( "'foo\\", 'foo', '' ), // Invalid - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: \\"' => array( '\\"' ), - 'Invalid: .foo' => array( '.foo' ), - 'Invalid: #foo' => array( '#foo' ), + 'Invalid: (empty string)' => array( '' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: \\"' => array( '\\"' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), ); } @@ -249,15 +254,16 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin */ public static function data_type_selectors(): array { return array( - 'any *' => array( '* .class', '*', ' .class' ), - 'a' => array( 'a', 'a', '' ), - 'div.class' => array( 'div.class', 'div', '.class' ), - 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), - // invalid - '#id' => array( '#id' ), - '.class' => array( '.class' ), - '[attr]' => array( '[attr]' ), + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: #id' => array( '#id' ), + 'Invalid: .class' => array( '.class' ), + 'Invalid: [attr]' => array( '[attr]' ), ); } @@ -313,6 +319,7 @@ public static function data_attribute_selectors(): array { '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid + 'Invalid: (empty string)' => array( '' ), 'Invalid: foo' => array( 'foo' ), 'Invalid: [foo' => array( '[foo' ), 'Invalid: [#foo]' => array( '[#foo]' ), @@ -321,12 +328,14 @@ public static function data_attribute_selectors(): array { 'Invalid: [* |att]' => array( '[* |att]' ), 'Invalid: [*| att]' => array( '[*| att]' ), 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), 'Invalid: [att="val" I]' => array( '[att="val" I]' ), 'Invalid: [att="val" S]' => array( '[att="val" S]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), ); } } From 2f8bd19efec5fb4f5f6cabd51d7173642d79af34 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:33:01 +0100 Subject: [PATCH 041/336] Improve documentation --- .../html-api/class-wp-css-selectors.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 3a4c0a7577679..669c74c1b676d 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -260,10 +260,10 @@ protected static function parse_string( string $input, int &$offset ): ?string { while ( $updated_offset < strlen( $input ) ) { switch ( $input[ $updated_offset ] ) { case '\\': - if ( $updated_offset + 1 >= strlen( $input ) ) { + ++$updated_offset; + if ( $updated_offset >= strlen( $input ) ) { break; } - ++$updated_offset; if ( "\n" === $input[ $updated_offset ] ) { ++$updated_offset; break; @@ -386,6 +386,11 @@ protected static function next_two_are_valid_escape( string $input, int $offset } /** + * Check if the next code point is an "ident start code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * * > ident-start code point * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). * > uppercase letter @@ -396,12 +401,10 @@ protected static function next_two_are_valid_escape( string $input, int $offset * > An uppercase letter or a lowercase letter. * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . + * + * https://www.w3.org/TR/css-syntax-3/#ident-start-code-point */ protected static function is_ident_start_codepoint( string $input, int $offset ): bool { - if ( $offset >= strlen( $input ) ) { - return false; - } - return ( '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || @@ -411,10 +414,17 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) } /** + * Check if the next code point is an "ident code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * * > ident code point * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + * + * https://www.w3.org/TR/css-syntax-3/#ident-code-point */ protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || From 8b0ac551e7694d3de921d84e60afe372583558b8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:37:26 +0100 Subject: [PATCH 042/336] Fix parse return type and return annotations --- .../html-api/class-wp-css-selectors.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 669c74c1b676d..6a80ca2e42b7c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -186,6 +186,8 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri * > Reconsume the current input code point. Return result. * * https://www.w3.org/TR/css-syntax-3/#consume-name + * + * @return string|null */ protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { @@ -243,6 +245,8 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * This implementation will never return a because * the is not a part of the selector grammar. That * case is treated as failure to parse and null is returned. + * + * @return string|null */ protected static function parse_string( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) ) { @@ -509,6 +513,8 @@ private function __construct( string $ident ) { * > = * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); @@ -533,6 +539,8 @@ private function __construct( string $ident ) { * > = '.' * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { @@ -574,10 +582,12 @@ private function __construct( string $ident ) { * so this selector effectively matches * or ident. * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { if ( $offset >= strlen( $input ) ) { - return false; + return null; } if ( '*' === $input[ $offset ] ) { @@ -697,11 +707,13 @@ private function __construct( string $name, ?string $matcher = null, ?string $va * Namespaces are not supported, so attribute names are effectively identifiers. * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { // Need at least 3 bytes [x] if ( $offset + 2 >= strlen( $input ) ) { - return false; + return null; } $updated_offset = $offset; From dffcac6ed016f727aaacfb192f151f5c3cb3c67f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 17:07:48 +0100 Subject: [PATCH 043/336] Update documentation links and grammar --- .../html-api/class-wp-css-selectors.php | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 6a80ca2e42b7c..264f684692f17 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -19,7 +19,29 @@ * is invalid or unsupported. * * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax - * specification, which is available at https://www.w3.org/TR/css-syntax-3/. + * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. + * + * @todo Review this grammar, especially the complex selector for accurate support information. + * The supported grammar is: + * + * = + * = # + * = # + * = # + * = [ ? ]* + * = [ ? * ]! + * = | + * = '>' | '+' | '~' | [ '|' '|' ] + * = | '*' + * = | | + * = + * = '.' + * = '[' ']' | + * '[' [ | ] ? ']' + * = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * = i | s + * + * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) @@ -43,10 +65,10 @@ * * @access private * - * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token - * @see https://www.w3.org/tr/selectors/#parse-selector - * @see https://www.w3.org/TR/selectors-api2/ - * @see https://www.w3.org/TR/selectors-4/ + * @see {@link https://www.w3.org/TR/css-syntax-3/} + * @see {@link https://www.w3.org/tr/selectors/} + * @see {@link https://www.w3.org/TR/selectors-api2/} + * @see {@link https://www.w3.org/TR/selectors-4/} * */ class WP_CSS_Selectors { From 9f81744aa7bc68fda9269d48251fa13fb2223519 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 20:29:56 +0100 Subject: [PATCH 044/336] Update documentation and class name --- src/wp-includes/html-api/class-wp-css-selectors.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 264f684692f17..d9bbc4b9235c8 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -14,7 +14,7 @@ * * This class is designed for internal use by the HTML processor. * - * This class is instantiated via the `WP_CSS_Selector::from_selector( string $selector )` method. + * This class is instantiated via the `WP_CSS_Selector_List::from_selector( string $selector )` method. * It accepts a CSS selector string and returns an instance of itself or `null` if the selector * is invalid or unsupported. * @@ -27,10 +27,8 @@ * = * = # * = # - * = # * = [ ? ]* * = [ ? * ]! - * = | * = '>' | '+' | '~' | [ '|' '|' ] * = | '*' * = | | @@ -71,7 +69,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selectors { +class WP_CSS_Selector_List { private $selectors; private function __construct( array $selectors ) { @@ -131,7 +129,7 @@ private static function parse( string $input ) { } } if ( count( $selectors ) ) { - return new WP_CSS_Selectors( $selectors ); + return new WP_CSS_Selector_List( $selectors ); } return null; } From d4c6f382dc246e151dc688a70daf88ad8a9f7916 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 20:30:12 +0100 Subject: [PATCH 045/336] Add selector class --- .../html-api/class-wp-css-selectors.php | 64 +++++++++++++++++++ .../phpunit/tests/html-api/wpCssSelectors.php | 18 ++++++ 2 files changed, 82 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index d9bbc4b9235c8..8d8ec35de98b6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -838,3 +838,67 @@ public static function parse( string $input, int &$offset ): ?self { return null; } } + +/** + * This corresponds to in the grammar. + */ +final class WP_CSS_Selector extends WP_CSS_Selector_Parser { + + /** @var WP_CSS_Type_Selector|null */ + public $type_selector; + + /** @var array|null */ + public $subclass_selectors; + + private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } + + /** + * Parses a selector string into a `WP_CSS_Selector` object. + * + * > = [ ? * ]! + * + * @param string $input The selector string to parse. + * @return WP_CSS_Selector|null The parsed selector, or `null` if the selector is invalid or unsupported. + */ + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = WP_CSS_Type_Selector::parse( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new self( $type_selector, $subclass_selectors ); + } + } + + /** + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char ? + WP_CSS_Class_Selector::parse( $input, $offset ) : ( + '#' === $next_char ? + WP_CSS_ID_Selector::parse( $input, $offset ) : ( + '[' === $next_char ? + WP_CSS_Attribute_Selector::parse( $input, $offset ) : + null ) ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7b6e5ce79a365..180bee4f53c05 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -338,4 +338,22 @@ public static function data_attribute_selectors(): array { 'Invalid: [att=val i ' => array( '[att=val i ' ), ); } + + /** + * @ticket TBD + */ + public function test_parse_selector() { + $input = 'el.foo#bar[baz=quux] > .child'; + $offset = 0; + $sel = WP_CSS_Selector::parse( $input, $offset ); + + $this->assertSame( $sel->type_selector->ident, 'el' ); + $this->assertSame( count( $sel->subclass_selectors ), 3 ); + $this->assertSame( $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( $sel->subclass_selectors[2]->matcher, WP_CSS_Attribute_Selector::MATCH_EXACT ); + $this->assertSame( $sel->subclass_selectors[2]->value, 'quux' ); + $this->assertSame( ' > .child', substr( $input, $offset ) ); + } } From 6432056bd38a8aebb94c51b6bfe6ac87353181c7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 21:01:43 +0100 Subject: [PATCH 046/336] Implement complex selector --- .../html-api/class-wp-css-selectors.php | 87 +++++++++++++++++-- .../phpunit/tests/html-api/wpCssSelectors.php | 34 ++++++-- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8d8ec35de98b6..8ccec5de029cc 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -123,9 +123,9 @@ private static function parse( string $input ) { $offset = 0; while ( $offset < $length ) { - $sel = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( $sel ) { - $selectors[] = $sel; + $selector = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( null !== $selector ) { + $selectors[] = $selector; } } if ( count( $selectors ) ) { @@ -841,6 +841,8 @@ public static function parse( string $input, int &$offset ): ?self { /** * This corresponds to in the grammar. + * + * > = [ ? * ]! */ final class WP_CSS_Selector extends WP_CSS_Selector_Parser { @@ -856,12 +858,7 @@ private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subcl } /** - * Parses a selector string into a `WP_CSS_Selector` object. - * * > = [ ? * ]! - * - * @param string $input The selector string to parse. - * @return WP_CSS_Selector|null The parsed selector, or `null` if the selector is invalid or unsupported. */ public static function parse( string $input, int &$offset ): ?self { if ( $offset >= strlen( $input ) ) { @@ -882,6 +879,7 @@ public static function parse( string $input, int &$offset ): ?self { $offset = $updated_offset; return new self( $type_selector, $subclass_selectors ); } + return null; } /** @@ -902,3 +900,76 @@ private static function parse_subclass_selector( string $input, int &$offset ) { null ) ); } } + + +/** + * This corresponds to in the grammar. + * + * > = [ ? ]* + */ +final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * even indexes are WP_CSS_Selector, odd indexes are string combinators. + * @var array + */ + public $selectors = array(); + + private function __construct( array $selectors ) { + $this->selectors = $selectors; + } + + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + switch ( $input[ $updated_offset ] ) { + case self::COMBINATOR_CHILD: + case self::COMBINATOR_NEXT_SIBLING: + case self::COMBINATOR_SUBSEQUENT_SIBLING: + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + break; + + default: + /* + * Whitespace is a descendant combinator. + * Either whitespace was found and we're on a selector, + * or we've failed to find any combinator and parsing is complete. + */ + if ( ! $found_whitespace ) { + break 2; + } + $combinator = self::COMBINATOR_DESCENDANT; + break; + } + // Here we've found a combinator and need another selector. + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + // Failure to find a selector is a parse error. + if ( null === $selector ) { + return null; + } + $selectors[] = $combinator; + $selectors[] = $selector; + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new self( $selectors ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 180bee4f53c05..4189ec586011a 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -347,13 +347,33 @@ public function test_parse_selector() { $offset = 0; $sel = WP_CSS_Selector::parse( $input, $offset ); - $this->assertSame( $sel->type_selector->ident, 'el' ); - $this->assertSame( count( $sel->subclass_selectors ), 3 ); - $this->assertSame( $sel->subclass_selectors[0]->ident, 'foo' ); - $this->assertSame( $sel->subclass_selectors[1]->ident, 'bar' ); - $this->assertSame( $sel->subclass_selectors[2]->name, 'baz' ); - $this->assertSame( $sel->subclass_selectors[2]->matcher, WP_CSS_Attribute_Selector::MATCH_EXACT ); - $this->assertSame( $sel->subclass_selectors[2]->value, 'quux' ); + $this->assertSame( 'el', $sel->type_selector->ident ); + $this->assertSame( 3, count( $sel->subclass_selectors ) ); + $this->assertSame( 'foo', $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( 'bar', $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); + $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); $this->assertSame( ' > .child', substr( $input, $offset ) ); } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector() { + $input = 'el.foo#bar[baz=quux] > .child, rest'; + $offset = 0; + $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); + + var_dump( $sel ); + $this->assertSame( 3, count( $sel->selectors ) ); + $this->assertNotNull( $sel->selectors[0]->type_selector ); + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + $this->assertNull( $sel->selectors[2]->type_selector ); + $this->assertSame( 1, count( $sel->selectors[2]->subclass_selectors ) ); + $this->assertSame( 'child', $sel->selectors[2]->subclass_selectors[0]->ident ); + + $this->assertSame( ', rest', substr( $input, $offset ) ); + } } From 5c746cd58b3e1178e9579e11b71974a5be652ac2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 22:39:22 +0100 Subject: [PATCH 047/336] Working and tested --- .../html-api/class-wp-css-selectors.php | 83 +++++++++++-------- .../phpunit/tests/html-api/wpCssSelectors.php | 67 ++++++++++++++- 2 files changed, 113 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8ccec5de029cc..734c3e38d094b 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -117,21 +117,31 @@ private static function parse( string $input ) { $input = str_replace( array( "\r", "\f" ), "\n", $input ); $input = str_replace( "\0", "\u{FFFD}", $input ); - $length = strlen( $input ); - $selectors = array(); - $offset = 0; - while ( $offset < $length ) { - $selector = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( null !== $selector ) { - $selectors[] = $selector; - } + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + if ( null === $selector ) { + return null; } - if ( count( $selectors ) ) { - return new WP_CSS_Selector_List( $selectors ); + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); } - return null; + + return new WP_CSS_Selector_List( $selectors ); } } @@ -145,7 +155,7 @@ public static function parse( string $input, int &$offset ); abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; - protected static function parse_whitespace( string $input, int &$offset ): bool { + public static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; @@ -938,35 +948,38 @@ public static function parse( string $input, int &$offset ): ?self { $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { - switch ( $input[ $updated_offset ] ) { - case self::COMBINATOR_CHILD: - case self::COMBINATOR_NEXT_SIBLING: - case self::COMBINATOR_SUBSEQUENT_SIBLING: + if ( + self::COMBINATOR_CHILD === $input[ $updated_offset ] || + self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { $combinator = $input[ $updated_offset ]; ++$updated_offset; self::parse_whitespace( $input, $updated_offset ); - break; - default: - /* - * Whitespace is a descendant combinator. - * Either whitespace was found and we're on a selector, - * or we've failed to find any combinator and parsing is complete. - */ - if ( ! $found_whitespace ) { - break 2; - } - $combinator = self::COMBINATOR_DESCENDANT; + // Failure to find a selector here is a parse error + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + // Failure to find a selector is a parse error. + if ( null === $selector ) { + return null; + } + $selectors[] = $combinator; + $selectors[] = $selector; + } elseif ( ! $found_whitespace ) { + break; + } else { + + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + if ( null === $selector ) { break; + } + $selectors[] = self::COMBINATOR_DESCENDANT; + $selectors[] = $selector; } - // Here we've found a combinator and need another selector. - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - // Failure to find a selector is a parse error. - if ( null === $selector ) { - return null; - } - $selectors[] = $combinator; - $selectors[] = $selector; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 4189ec586011a..33ada4ccbe3f9 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -357,15 +357,24 @@ public function test_parse_selector() { $this->assertSame( ' > .child', substr( $input, $offset ) ); } + /** + * @ticket TBD + */ + public function test_parse_empty_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + /** * @ticket TBD */ public function test_parse_complex_selector() { - $input = 'el.foo#bar[baz=quux] > .child, rest'; + $input = 'el.foo#bar[baz=quux] > .child , rest'; $offset = 0; $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); - var_dump( $sel ); $this->assertSame( 3, count( $sel->selectors ) ); $this->assertNotNull( $sel->selectors[0]->type_selector ); $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); @@ -376,4 +385,58 @@ public function test_parse_complex_selector() { $this->assertSame( ', rest', substr( $input, $offset ) ); } + + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + + /** + * @ticket TBD + */ + public function test_parse_selector_list() { + $input = 'el.foo#bar[baz=quux] .descendent , rest'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } } From 501102a87bb3f38bc2781c22b6de9a59d640bf62 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 18:30:47 +0100 Subject: [PATCH 048/336] Selector parsing should allow cap I,S modifier --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 734c3e38d094b..6e382f8f8b744 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -823,11 +823,13 @@ public static function parse( string $input, int &$offset ): ?self { $attr_modifier = null; switch ( $input[ $updated_offset ] ) { case 'i': + case 'I': $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; ++$updated_offset; break; case 's': + case 'S': $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; ++$updated_offset; break; From f98fbb39c71333b22e3c7f97c380c7ce81c56097 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:08:17 +0100 Subject: [PATCH 049/336] CSS Add matches to selector classes --- .../html-api/class-wp-css-selectors.php | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 6e382f8f8b744..d9c507bb5f557 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -69,7 +69,20 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List { +class WP_CSS_Selector_List implements IWP_CSS_Selector_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $processor->get_token_type() !== '#tag' ) { + return false; + } + + foreach ( $this->selectors as $selector ) { + if ( ! $selector->matches( $processor ) ) { + return false; + } + } + return true; + } + private $selectors; private function __construct( array $selectors ) { @@ -145,6 +158,13 @@ private static function parse( string $input ) { } } +interface IWP_CSS_Selector_Matcher { + /** + * @return bool + */ + public function matches( WP_HTML_Processor $processor ): bool; +} + interface IWP_CSS_Selector_Parser { /** * @return static|null @@ -152,7 +172,7 @@ interface IWP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ); } -abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { +abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; public static function parse_whitespace( string $input, int &$offset ): bool { @@ -553,9 +573,18 @@ public static function parse( string $input, int &$offset ): ?self { } return new self( $ident ); } + + public function matches( WP_HTML_Processor $processor ): bool { + // @todo check case sensitivity. + return $processor->get_attribute( 'id' ) === $this->ident; + } } final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + return $processor->has_class( $this->ident ); + } + /** @var string */ public $ident; @@ -590,6 +619,13 @@ public static function parse( string $input, int &$offset ): ?self { } final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + if ( '*' === $this->ident ) { + return true; + } + return 0 === strcasecmp( $processor->get_tag(), $this->ident ); + } + /** * @var string * @@ -635,9 +671,64 @@ public static function parse( string $input, int &$offset ): ?self { } final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + $att_value = $processor->get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive ? + 0 === strcasecmp( $att_value, $this->value ) : + $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + // @todo + throw new Exception( 'One of attribute matching is not supported yet.' ); + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive ? + 0 === strcasecmp( $att_value, $this->value ) : + $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive ? + stripos( $att_value, $this->value ) : + strpos( $att_value, $this->value ) + ); + } + } + /** - * [attr=value] - * Represents elements with an attribute name of attr whose value is exactly value. + * [att=val] + * Represents an element with the att attribute whose value is exactly "val". */ const MATCH_EXACT = 'MATCH_EXACT'; @@ -857,6 +948,19 @@ public static function parse( string $input, int &$offset ): ?self { * > = [ ? * ]! */ final class WP_CSS_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $this->type_selector ) { + if ( ! $this->type_selector->matches( $processor ) ) { + return false; + } + } + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } + } + return true; + } /** @var WP_CSS_Type_Selector|null */ public $type_selector; @@ -920,6 +1024,14 @@ private static function parse_subclass_selector( string $input, int &$offset ) { * > = [ ? ]* */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + // @todo this can throw on parse. + if ( count( $this->selectors ) > 1 ) { + throw new Exception( 'Combined complex selectors are not supported yet.' ); + } + return $this->selectors[0]->matches( $processor ); + } + const COMBINATOR_CHILD = '>'; const COMBINATOR_DESCENDANT = ' '; const COMBINATOR_NEXT_SIBLING = '+'; From c8f16e19f30ec4b4ad0cfbaac849b33e811229e3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:40:55 +0100 Subject: [PATCH 050/336] Match is successful on _any_ match in selector list --- src/wp-includes/html-api/class-wp-css-selectors.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index d9c507bb5f557..1a50defba8ea3 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -76,11 +76,11 @@ public function matches( WP_HTML_Processor $processor ): bool { } foreach ( $this->selectors as $selector ) { - if ( ! $selector->matches( $processor ) ) { - return false; + if ( $selector->matches( $processor ) ) { + return true; } } - return true; + return false; } private $selectors; From c689c9c50fb6827dd330df1707844410479b4234 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:41:55 +0100 Subject: [PATCH 051/336] PICKME: Add is_quirks_mode method to processor --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ++++ 1 file changed, 4 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 39390621e86a6..7dadbc1bebdb2 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 @@ -537,6 +537,10 @@ class WP_HTML_Tag_Processor { */ protected $compat_mode = self::NO_QUIRKS_MODE; + public function is_quirks_mode() { + return self::QUIRKS_MODE === $this->compat_mode; + } + /** * Indicates whether the parser is inside foreign content, * e.g. inside an SVG or MathML element. From 1221efae34bf033af893180aa32a13e58b5312d8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:41:27 +0100 Subject: [PATCH 052/336] ID matches depend on quirks mode --- src/wp-includes/html-api/class-wp-css-selectors.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 1a50defba8ea3..01e3253893d57 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -575,8 +575,10 @@ public static function parse( string $input, int &$offset ): ?self { } public function matches( WP_HTML_Processor $processor ): bool { - // @todo check case sensitivity. - return $processor->get_attribute( 'id' ) === $this->ident; + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + return $case_insensitive ? + 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) : + $processor->get_attribute( 'id' ) === $this->ident; } } From e5e94b11b5d9e3c113364c2a595ebb8cfdb715f7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:42:12 +0100 Subject: [PATCH 053/336] has_class may return null, coerce to bool --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 01e3253893d57..3e35a383b4446 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -584,7 +584,7 @@ public function matches( WP_HTML_Processor $processor ): bool { final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { public function matches( WP_HTML_Processor $processor ): bool { - return $processor->has_class( $this->ident ); + return (bool) $processor->has_class( $this->ident ); } /** @var string */ From 1e888babcc7e4448a02ace55a509e655bdea1e5d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 21:29:13 +0100 Subject: [PATCH 054/336] Update docs to only allow subclass selectors in final complex selector position --- .../html-api/class-wp-css-selectors.php | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 3e35a383b4446..b0d5afbb5bba7 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -27,9 +27,9 @@ * = * = # * = # - * = [ ? ]* + * = [ ? ]* * = [ ? * ]! - * = '>' | '+' | '~' | [ '|' '|' ] + * = '>' | [ '|' '|' ] * = | '*' * = | | * = @@ -47,17 +47,23 @@ * - ID selectors (e.g. `#unique-id`) * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) - * - The following combinators: - * - descendant (e.g. `.parent .descendant`) - * - child (`.parent > .child`) + * - The following combinators. Only type (element) selectors are allowed in non-final position: + * - descendant (e.g. `el .descendant`) + * - child (`el > .child`) * * Unsupported selector syntax: * - Pseudo-element selectors (e.g. `::before`) * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) * - The following combinators: - * - Next sibling (`.sibling + .sibling`) - * - Subsequent sibling (`.sibling ~ .sibling`) + * - Next sibling (`el + el`) + * - Subsequent sibling (`el ~ el`) + * + * Future ideas + * - Namespace type selectors could be implemented with select namespaces in order to + * select elements from a namespace, for example: + * - `svg|*` to select all SVG elements + * - `html|title` to select only HTML TITLE elements. * * @since TBD * From dd4fcb01184f9e07ec51067e1d7c1a8d4021d168 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:10:05 +0100 Subject: [PATCH 055/336] Restrict complex selectors to only allow subclass selectors in final position --- .../html-api/class-wp-css-selectors.php | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index b0d5afbb5bba7..45a2f78d94fd5 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1066,7 +1066,8 @@ public static function parse( string $input, int &$offset ): ?self { return null; } - $selectors = array( $selector ); + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { @@ -1075,22 +1076,13 @@ public static function parse( string $input, int &$offset ): ?self { self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // Failure to find a selector here is a parse error - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - // Failure to find a selector is a parse error. - if ( null === $selector ) { - return null; - } - $selectors[] = $combinator; - $selectors[] = $selector; - } elseif ( ! $found_whitespace ) { - break; - } else { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + // Failure to find a selector here is a parse error + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + } elseif ( $found_whitespace ) { /* * Whitespace is ambiguous, it could be a descendant combinator or * insignificant whitespace. @@ -1099,9 +1091,24 @@ public static function parse( string $input, int &$offset ): ?self { if ( null === $selector ) { break; } - $selectors[] = self::COMBINATOR_DESCENDANT; - $selectors[] = $selector; + $combinator = self::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; From 256c55a16d8e5adf3ebdc64a360e3373eeecaa28 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:10:21 +0100 Subject: [PATCH 056/336] Work on complex selector handling --- .../html-api/class-wp-css-selectors.php | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 45a2f78d94fd5..bc28cfaa4f20e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1033,11 +1033,47 @@ private static function parse_subclass_selector( string $input, int &$offset ) { */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { public function matches( WP_HTML_Processor $processor ): bool { - // @todo this can throw on parse. - if ( count( $this->selectors ) > 1 ) { - throw new Exception( 'Combined complex selectors are not supported yet.' ); + if ( count( $this->selectors ) === 1 ) { + return $this->selectors[0]->matches( $processor ); + } + + // First selector must match this location. + if ( ! $this->selectors[0]->matches( $processor ) ) { + return false; + } + + $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); + $selectors = array_slice( $this->selectors, 1 ); + return $this->explore_matches( $selectors, $breadcrumbs ); + } + + /** + * This only looks at breadcrumbs and can therefore only support type selectors. + * + * @param array $selectors + */ + private function explore_matches( array $selectors, array $breadcrumbs ): bool { + if ( array() === $selectors ) { + return true; + } + if ( array() === $breadcrumbs ) { + return false; + } + + $combinator = $selectors[0]; + $selector = $selectors[1]; + + switch ( $combinator ) { + case self::COMBINATOR_CHILD: + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + } + return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + + case self::COMBINATOR_DESCENDANT: + default: + throw new Exception( "Combinator '{$combinator}' is not supported yet." ); } - return $this->selectors[0]->matches( $processor ); } const COMBINATOR_CHILD = '>'; @@ -1047,12 +1083,15 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * even indexes are WP_CSS_Selector, odd indexes are string combinators. + * In reverse order to match the current element and then work up the tree. + * Any non-final selector is a type selector. + * * @var array */ public $selectors = array(); private function __construct( array $selectors ) { - $this->selectors = $selectors; + $this->selectors = array_reverse( $selectors ); } public static function parse( string $input, int &$offset ): ?self { From 465cc3673cb15e2b229767223801224d8fd36335 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:26:43 +0100 Subject: [PATCH 057/336] Implement descendent selector matching --- src/wp-includes/html-api/class-wp-css-selectors.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index bc28cfaa4f20e..974c56e6581ff 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1071,6 +1071,19 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); case self::COMBINATOR_DESCENDANT: + $ident = $selector->type_selector->ident; + + // Find _all_ the breadcrumbs that match and recurse from each of them. + for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + return true; + } + } + } + return false; + default: throw new Exception( "Combinator '{$combinator}' is not supported yet." ); } From 467d45dc3133dfefb7081e8e7e7821254dd073a0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 15:48:21 +0100 Subject: [PATCH 058/336] Add null check for subclass selectors --- src/wp-includes/html-api/class-wp-css-selectors.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 974c56e6581ff..21039c0c7940e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -962,9 +962,11 @@ public function matches( WP_HTML_Processor $processor ): bool { return false; } } - foreach ( $this->subclass_selectors as $subclass_selector ) { - if ( ! $subclass_selector->matches( $processor ) ) { - return false; + if ( null !== $this->subclass_selectors ) { + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } } } return true; From 44bfc64b4fe9711f1800e854c059156bcf2b45fb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:20:22 +0100 Subject: [PATCH 059/336] CSS selector reformat ternaries --- .../html-api/class-wp-css-selectors.php | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 21039c0c7940e..65e384639abcb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -390,9 +390,9 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) - ) ? - "\u{FFFD}" : - mb_chr( $codepoint_value, 'UTF-8' ); + ) + ? "\u{FFFD}" + : mb_chr( $codepoint_value, 'UTF-8' ); $offset += $hex_length; @@ -582,9 +582,9 @@ public static function parse( string $input, int &$offset ): ?self { public function matches( WP_HTML_Processor $processor ): bool { $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); - return $case_insensitive ? - 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) : - $processor->get_attribute( 'id' ) === $this->ident; + return $case_insensitive + ? 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) + : $processor->get_attribute( 'id' ) === $this->ident; } } @@ -693,9 +693,9 @@ public function matches( WP_HTML_Processor $processor ): bool { switch ( $this->matcher ) { case self::MATCH_EXACT: - return $case_insensitive ? - 0 === strcasecmp( $att_value, $this->value ) : - $att_value === $this->value; + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; case self::MATCH_ONE_OF_EXACT: // @todo @@ -704,9 +704,9 @@ public function matches( WP_HTML_Processor $processor ): bool { case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: // Attempt the full match first if ( - $case_insensitive ? - 0 === strcasecmp( $att_value, $this->value ) : - $att_value === $this->value + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value ) { return true; } @@ -1017,13 +1017,16 @@ private static function parse_subclass_selector( string $input, int &$offset ) { } $next_char = $input[ $offset ]; - return '.' === $next_char ? - WP_CSS_Class_Selector::parse( $input, $offset ) : ( - '#' === $next_char ? - WP_CSS_ID_Selector::parse( $input, $offset ) : ( - '[' === $next_char ? - WP_CSS_Attribute_Selector::parse( $input, $offset ) : - null ) ); + return '.' === $next_char + ? WP_CSS_Class_Selector::parse( $input, $offset ) + : ( + '#' === $next_char + ? WP_CSS_ID_Selector::parse( $input, $offset ) + : ( '[' === $next_char + ? WP_CSS_Attribute_Selector::parse( $input, $offset ) + : null + ) + ); } } From ca4531c0a190b89f6072799b2b1f90dbd1deb2c1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:20:54 +0100 Subject: [PATCH 060/336] Implement ~= attribute matching --- .../html-api/class-wp-css-selectors.php | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 65e384639abcb..49c3daf66c3b2 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -180,9 +180,10 @@ public static function parse( string $input, int &$offset ); abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; + const WHITESPACE_CHARACTERS = " \t\r\n\f"; public static function parse_whitespace( string $input, int &$offset ): bool { - $length = strspn( $input, " \t\r\n\f", $offset ); + $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $advanced = $length > 0; $offset += $length; return $advanced; @@ -698,8 +699,16 @@ public function matches( WP_HTML_Processor $processor ): bool { : $att_value === $this->value; case self::MATCH_ONE_OF_EXACT: - // @todo - throw new Exception( 'One of attribute matching is not supported yet.' ); + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: // Attempt the full match first @@ -727,13 +736,35 @@ public function matches( WP_HTML_Processor $processor ): bool { case self::MATCH_CONTAINS: return false !== ( - $case_insensitive ? - stripos( $att_value, $this->value ) : - strpos( $att_value, $this->value ) + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) ); } } + /** + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); + if ( 0 === $length ) { + return; + } + + $value = substr( $input, $offset, $length ); + $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); + + yield $value; + } + } + /** * [att=val] * Represents an element with the att attribute whose value is exactly "val". From 489db93a917625bc7d42d6e3d9f5ad924d3a96ed Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:48:15 +0100 Subject: [PATCH 061/336] CSS fix return type --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 49c3daf66c3b2..1431dc58afb52 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -113,7 +113,7 @@ public static function from_selectors( string $selectors ): ?self { * * @since TBD * - * @return WP_CSS_Selectors|null + * @return self|null */ private static function parse( string $input ) { // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. From e57a2114aafdd6cb1d0e3cf1b7d2e3064c3e8d0b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:05:40 +0100 Subject: [PATCH 062/336] Fix static analysis problems --- .../html-api/class-wp-css-selectors.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 1431dc58afb52..2205146bdf2be 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1,4 +1,8 @@ -value ) ); } + + throw new Exception( 'Unreachable' ); } /** @@ -830,7 +834,7 @@ private function whitespace_delimited_list( string $input ): Generator { /** * The attribute matcher. * - * @var string|null + * @var null|self::MATCH_* */ public $matcher; @@ -844,7 +848,7 @@ private function whitespace_delimited_list( string $input ): Generator { /** * The attribute modifier. * - * @var string|null + * @var null|self::MODIFIER_* */ public $modifier; @@ -1086,7 +1090,7 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array $selectors */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -1096,8 +1100,10 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } + /** @var self::COMBINATOR_* $combinator */ $combinator = $selectors[0]; - $selector = $selectors[1]; + /** @var WP_CSS_Selector $selector */ + $selector = $selectors[1]; switch ( $combinator ) { case self::COMBINATOR_CHILD: @@ -1107,8 +1113,6 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); case self::COMBINATOR_DESCENDANT: - $ident = $selector->type_selector->ident; - // Find _all_ the breadcrumbs that match and recurse from each of them. for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { From 509e648685af757a6b38830c8ccd58e2ac36fe07 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:40:39 +0100 Subject: [PATCH 063/336] Fix and annotate things (static analysis) --- .../html-api/class-wp-css-selectors.php | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 2205146bdf2be..28e51aa9a9735 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -77,7 +77,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List implements IWP_CSS_Selector_Matcher { +class WP_CSS_Selector_List extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; @@ -91,8 +91,14 @@ public function matches( WP_HTML_Processor $processor ): bool { return false; } + /** + * @var array + */ private $selectors; + /** + * @param array $selectors + */ private function __construct( array $selectors ) { $this->selectors = $selectors; } @@ -122,7 +128,7 @@ private static function parse( string $input ) { $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { - null; + return null; } /* @@ -144,7 +150,7 @@ private static function parse( string $input ) { if ( null === $selector ) { return null; } - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); $selectors = array( $selector ); while ( $offset < strlen( $input ) ) { @@ -153,16 +159,16 @@ private static function parse( string $input ) { return null; } ++$offset; - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } $selectors[] = $selector; - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); } - return new WP_CSS_Selector_List( $selectors ); + return new self( $selectors ); } } @@ -180,7 +186,7 @@ interface IWP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ); } -abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +abstract class WP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; @@ -216,7 +222,6 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri if ( null === $result ) { return null; - $offset = $updated_offset; } $offset = $updated_offset; @@ -263,8 +268,8 @@ protected static function parse_ident( string $input, int &$offset ): ?string { continue; } elseif ( self::is_ident_codepoint( $input, $offset ) ) { // @todo this should append and advance the correct number of bytes. - $ident .= $input[ $offset ]; - $offset += 1; + $ident .= $input[ $offset ]; + ++$offset; continue; } break; @@ -378,6 +383,10 @@ protected static function parse_string( string $input, int &$offset ): ?string { * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). * > anything else * > Return the current input code point. + * + * @param string $input + * @param int $offset + * @return string|null */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); @@ -558,7 +567,8 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { + /** @var string */ public $ident; @@ -591,7 +601,7 @@ public function matches( WP_HTML_Processor $processor ): bool { } } -final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { return (bool) $processor->has_class( $this->ident ); } @@ -629,7 +639,7 @@ public static function parse( string $input, int &$offset ): ?self { } } -final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( '*' === $this->ident ) { return true; @@ -681,7 +691,7 @@ public static function parse( string $input, int &$offset ): ?self { } } -final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { $att_value = $processor->get_attribute( $this->name ); if ( null === $att_value ) { @@ -990,7 +1000,7 @@ public static function parse( string $input, int &$offset ): ?self { * * > = [ ? * ]! */ -final class WP_CSS_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { @@ -1013,6 +1023,10 @@ public function matches( WP_HTML_Processor $processor ): bool { /** @var array|null */ public $subclass_selectors; + /** + * @param WP_CSS_Type_Selector|null $type_selector + * @param array $subclass_selectors + */ private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { $this->type_selector = $type_selector; $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; @@ -1071,7 +1085,7 @@ private static function parse_subclass_selector( string $input, int &$offset ) { * * > = [ ? ]* */ -final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( count( $this->selectors ) === 1 ) { return $this->selectors[0]->matches( $processor ); @@ -1091,6 +1105,7 @@ public function matches( WP_HTML_Processor $processor ): bool { * This only looks at breadcrumbs and can therefore only support type selectors. * * @param array $selectors + * @param array $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -1139,10 +1154,13 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { * In reverse order to match the current element and then work up the tree. * Any non-final selector is a type selector. * - * @var array + * @var array */ public $selectors = array(); + /** + * @param array $selectors + */ private function __construct( array $selectors ) { $this->selectors = array_reverse( $selectors ); } From 58c1698b16a55ac3d9bc92c35b4c2346e43b67c7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:40:46 +0100 Subject: [PATCH 064/336] update tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 33ada4ccbe3f9..5983f91c5d9ba 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -309,8 +309,12 @@ public static function data_attribute_selectors(): array { '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), @@ -332,8 +336,6 @@ public static function data_attribute_selectors(): array { 'Invalid: [att=val ' => array( '[att=val ' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), - 'Invalid: [att="val" I]' => array( '[att="val" I]' ), - 'Invalid: [att="val" S]' => array( '[att="val" S]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), 'Invalid: [att=val i ' => array( '[att=val i ' ), ); @@ -371,17 +373,21 @@ public function test_parse_empty_selector() { * @ticket TBD */ public function test_parse_complex_selector() { - $input = 'el.foo#bar[baz=quux] > .child , rest'; + $input = 'el1 > .child#bar[baz=quux] , rest'; $offset = 0; $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); $this->assertSame( 3, count( $sel->selectors ) ); - $this->assertNotNull( $sel->selectors[0]->type_selector ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + + $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); + $this->assertNull( $sel->selectors[2]->subclass_selectors ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); - $this->assertNull( $sel->selectors[2]->type_selector ); - $this->assertSame( 1, count( $sel->selectors[2]->subclass_selectors ) ); - $this->assertSame( 'child', $sel->selectors[2]->subclass_selectors[0]->ident ); + + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertNull( $sel->selectors[0]->type_selector ); + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); $this->assertSame( ', rest', substr( $input, $offset ) ); } @@ -408,7 +414,7 @@ public function test_parse_empty_complex_selector() { * @ticket TBD */ public function test_parse_selector_list() { - $input = 'el.foo#bar[baz=quux] .descendent , rest'; + $input = 'el1 el2 el.foo#bar[baz=quux], rest'; $result = WP_CSS_Selector_List::from_selectors( $input ); $this->assertNotNull( $result ); } From c9b914517674004d8b7c38099325183cf3a592a8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:44:21 +0100 Subject: [PATCH 065/336] Id attribute must be a string to match id selector --- src/wp-includes/html-api/class-wp-css-selectors.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 28e51aa9a9735..8af33c2194723 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -594,9 +594,14 @@ public static function parse( string $input, int &$offset ): ?self { } public function matches( WP_HTML_Processor $processor ): bool { + $id = $processor->get_attribute( 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); return $case_insensitive - ? 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) + ? 0 === strcasecmp( $id, $this->ident ) : $processor->get_attribute( 'id' ) === $this->ident; } } From e5cac63369f3c7b1a6cdf3c02c097bdae4e3d669 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:47:31 +0100 Subject: [PATCH 066/336] Coerce boolean attributes to "" --- src/wp-includes/html-api/class-wp-css-selectors.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8af33c2194723..8b92150cbef8f 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -707,6 +707,10 @@ public function matches( WP_HTML_Processor $processor ): bool { return true; } + if ( true === $att_value ) { + $att_value = ''; + } + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; switch ( $this->matcher ) { From 2bafae995a64897ec393167e8a7416b74ff8b485 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:56:57 +0100 Subject: [PATCH 067/336] Fix a few more static analysis things --- .../html-api/class-wp-css-selectors.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8b92150cbef8f..87e32727a434e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -871,6 +871,12 @@ private function whitespace_delimited_list( string $input ): Generator { */ public $modifier; + /** + * @param string $name + * @param null|self::MATCH_* $matcher + * @param null|string $value + * @param null|self::MODIFIER_* $modifier + */ private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; $this->matcher = $matcher; @@ -1092,19 +1098,20 @@ private static function parse_subclass_selector( string $input, int &$offset ) { /** * This corresponds to in the grammar. * - * > = [ ? ]* + * > = [ ? ] * */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { - if ( count( $this->selectors ) === 1 ) { - return $this->selectors[0]->matches( $processor ); - } - // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { return false; } + if ( count( $this->selectors ) === 1 ) { + return true; + } + + /** @var array $breadcrumbs */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); $selectors = array_slice( $this->selectors, 1 ); return $this->explore_matches( $selectors, $breadcrumbs ); From 8fe57e393d947c2b8db0ee326cfa7989ade8c801 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 18:04:13 +0100 Subject: [PATCH 068/336] Add select method --- .../html-api/class-wp-html-processor.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index e88757ec7b4c2..438dee4c47f4e 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -635,6 +635,44 @@ public function get_unsupported_exception() { return $this->unsupported_exception; } + /** + * Use a selector to advance. + * + * @param string $selectors + * @return Generator|null + */ + public function select_all( string $selectors ): ?Generator { + $select = WP_CSS_Selector_List::from_selectors( $selectors ); + if ( null === $select ) { + return null; + } + + while ( $this->next_tag() ) { + if ( $select->matches( $this ) ) { + yield; + } + } + } + + /** + * Select the next matching element. + * + * If iterating through matching elements, use `select_all` instead. + * + * @param string $selectors + * @return bool|null + */ + public function select( string $selectors ) { + $selection = $this->select_all( $selectors ); + if ( null === $selection ) { + return null; + } + foreach ( $selection as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * From ab2fe0d78e2f2f54b29dae6ddb36a664f703d476 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 18:20:01 +0100 Subject: [PATCH 069/336] Unify parsing under single class --- .../html-api/class-wp-css-selectors.php | 820 +++++++++--------- .../html-api/class-wp-html-processor.php | 2 +- .../phpunit/tests/html-api/wpCssSelectors.php | 121 ++- 3 files changed, 510 insertions(+), 433 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 87e32727a434e..7588eb72294bd 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -16,8 +16,8 @@ * * This class is designed for internal use by the HTML processor. * - * This class is instantiated via the `WP_CSS_Selector_List::from_selector( string $selector )` method. - * It accepts a CSS selector string and returns an instance of itself or `null` if the selector + * This class is instantiated via the `WP_CSS_Selector::from_selectors( string $input )` method. + * It takes a CSS selector string and returns an instance of itself or `null` if the selector * is invalid or unsupported. * * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax @@ -39,7 +39,7 @@ * = '[' ']' | * '[' [ | ] ? ']' * = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * = i | s + * = i | I | s | S * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * @@ -77,7 +77,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Matcher { +class WP_CSS_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; @@ -97,34 +97,25 @@ public function matches( WP_HTML_Processor $processor ): bool { private $selectors; /** + * Constructor. + * * @param array $selectors */ - private function __construct( array $selectors ) { + protected function __construct( array $selectors ) { $this->selectors = $selectors; } /** - * Takes a CSS selectors string and returns an instance of itself or `null` if the selector - * is invalid or unsupported. - * - * @since TBD - * - * @param string $selectors CSS selectors string. - * @return self|null - */ - public static function from_selectors( string $selectors ): ?self { - return self::parse( $selectors ); - } - - /** - * Returns a list of selectors. + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. * * @since TBD * + * @param string $input CSS selectors. * @return self|null */ - private static function parse( string $input ) { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. + public static function from_selectors( string $input ): ?self { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { @@ -146,7 +137,7 @@ private static function parse( string $input ) { $offset = 0; - $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -160,7 +151,7 @@ private static function parse( string $input ) { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -170,23 +161,343 @@ private static function parse( string $input ) { return new self( $selectors ); } -} -interface IWP_CSS_Selector_Matcher { + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + /** - * @return bool + * Parse an ID selector + * + * > = + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_ID_Selector|null */ - public function matches( WP_HTML_Processor $processor ): bool; -} + final protected static function parse_id_selector( string $input, int &$offset ): ?WP_CSS_ID_Selector { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new WP_CSS_ID_Selector( $ident ); + } -interface IWP_CSS_Selector_Parser { /** - * @return static|null + * Parse a class selector + * + * > = '.' + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Class_Selector|null + */ + final protected static function parse_class_selector( string $input, int &$offset ): ?WP_CSS_Class_Selector { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return new WP_CSS_Class_Selector( $result ); + } + + /** + * Parse a type selector + * + * > = | ? '*' + * > = [ | '*' ]? '|' + * > = ? + * + * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, + * so this selector effectively matches * or ident. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Type_Selector|null + */ + final protected static function parse_type_selector( string $input, int &$offset ): ?WP_CSS_Type_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new WP_CSS_Type_Selector( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new WP_CSS_Type_Selector( $result ); + } + + /** + * Parse an attribute selector + * + * > = '[' ']' | + * > '[' [ | ] ? ']' + * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * > = i | s + * > = ? + * + * Namespaces are not supported, so attribute names are effectively identifiers. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Attribute_Selector|null + */ + final protected static function parse_attribute_selector( string $input, int &$offset ): ?WP_CSS_Attribute_Selector { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + case 'I': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + case 'S': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } + + /** + * Parses a compound selector. + * + * > = [ ? * ]! + * + * @return WP_CSS_Compound_Selector|null + */ + final protected static function parse_compound_selector( string $input, int &$offset ): ?WP_CSS_Compound_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = self::parse_type_selector( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new WP_CSS_Compound_Selector( $type_selector, $subclass_selectors ); + } + return null; + } + + /** + * Parses a complex selector. + * + * > = [ ? ]* + * + * @return WP_CSS_Complex_Selector|null + */ + final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + + // Failure to find a selector here is a parse error + $selector = self::parse_compound_selector( $input, $updated_offset ); + } elseif ( $found_whitespace ) { + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + break; + } + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; + } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new WP_CSS_Complex_Selector( $selectors ); + } + + /** + * Parses a subclass selector. + * + * > = | | + * + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char + ? self::parse_class_selector( $input, $offset ) + : ( + '#' === $next_char + ? self::parse_id_selector( $input, $offset ) + : ( '[' === $next_char + ? self::parse_attribute_selector( $input, $offset ) + : null + ) + ); + } + + + /* + * ------------------------ + * Selector partial parsing + * ------------------------ + * + * These functions consume parts of a selector string input when successful + * and return meaningful values to be used by selectors. */ - public static function parse( string $input, int &$offset ); -} -abstract class WP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; @@ -212,7 +523,7 @@ public static function parse_whitespace( string $input, int &$offset ): bool { * * This implementation is not interested in the , a '#' delim token is not relevant for selectors. */ - protected static function parse_hash_token( string $input, int &$offset ): ?string { + final protected static function parse_hash_token( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { return null; } @@ -253,7 +564,7 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri * * @return string|null */ - protected static function parse_ident( string $input, int &$offset ): ?string { + final protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { return null; } @@ -312,7 +623,7 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * * @return string|null */ - protected static function parse_string( string $input, int &$offset ): ?string { + final protected static function parse_string( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) ) { return null; } @@ -388,16 +699,24 @@ protected static function parse_string( string $input, int &$offset ): ?string { * @param int $offset * @return string|null */ - protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + final protected static function consume_escaped_codepoint( $input, &$offset ): ?string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); if ( $hex_length > 0 ) { + /** + * The 6-character hex string has a maximum value of 0xFFFFFF. + * It is likely to fit in an int value and not be a float. + * + * @var int + */ $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); - // > A surrogate is a leading surrogate or a trailing surrogate. - // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. - // > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. - // The surrogate ranges are adjacent, so the complete range is 0xD800..=0xDFFF, - // inclusive. + /* + * > A surrogate is a leading surrogate or a trailing surrogate. + * > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + * > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + * + * The surrogate ranges are adjacent, so the complete range is 0xD800 to 0xDFFF, inclusive. + */ $codepoint_char = ( 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || @@ -428,13 +747,16 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string } /* - * Utiltities - * ========== + * --------------------------- + * Selector parsing utiltities + * --------------------------- * - * The following functions do not consume any input. + * The following functions are used for parsing but do not consume any input. */ /** + * Checks for two valid escape codepoints. + * * > 4.3.8. Check if two code points are a valid escape * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. * > @@ -449,8 +771,12 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape * * @todo this does not check whether the second codepoint is valid. + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next two codepoints are a valid escape, otherwise false. */ - protected static function next_two_are_valid_escape( string $input, int $offset ): bool { + private static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -458,7 +784,7 @@ protected static function next_two_are_valid_escape( string $input, int $offset } /** - * Check if the next code point is an "ident start code point". + * Checks if the next code point is an "ident start code point". * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -474,9 +800,13 @@ protected static function next_two_are_valid_escape( string $input, int $offset * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . * - * https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * @link https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident start code point, otherwise false. */ - protected static function is_ident_start_codepoint( string $input, int $offset ): bool { + final protected static function is_ident_start_codepoint( string $input, int $offset ): bool { return ( '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || @@ -486,7 +816,7 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) } /** - * Check if the next code point is an "ident code point". + * Checks if the next code point is an "ident code point". * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -496,15 +826,21 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. * - * https://www.w3.org/TR/css-syntax-3/#ident-code-point + * @link https://www.w3.org/TR/css-syntax-3/#ident-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident code point, otherwise false. */ - protected static function is_ident_codepoint( string $input, int $offset ): bool { + final protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); } /** + * Checks if three code points would start an ident sequence. + * * > 4.3.9. Check if three code points would start an ident sequence * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. * > @@ -521,9 +857,13 @@ protected static function is_ident_codepoint( string $input, int $offset ): bool * > anything else * > Return false. * - * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next three codepoints would start an ident sequence, otherwise false. */ - protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + private static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -567,32 +907,21 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +interface IWP_CSS_Selector_Matcher { + /** + * @return bool + */ + public function matches( WP_HTML_Processor $processor ): bool; +} +final class WP_CSS_ID_Selector implements IWP_CSS_Selector_Matcher { /** @var string */ public $ident; - private function __construct( string $ident ) { + public function __construct( string $ident ) { $this->ident = $ident; } - /** - * Parse an ID selector - * - * > = - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - $ident = self::parse_hash_token( $input, $offset ); - if ( null === $ident ) { - return null; - } - return new self( $ident ); - } - public function matches( WP_HTML_Processor $processor ): bool { $id = $processor->get_attribute( 'id' ); if ( ! is_string( $id ) ) { @@ -606,50 +935,29 @@ public function matches( WP_HTML_Processor $processor ): bool { } } -final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - return (bool) $processor->has_class( $this->ident ); - } - - /** @var string */ - public $ident; - - private function __construct( string $ident ) { - $this->ident = $ident; - } - - /** - * Parse a class selector - * - * > = '.' - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { - return null; - } - - $updated_offset = $offset + 1; - $result = self::parse_ident( $input, $updated_offset ); - - if ( null === $result ) { - return null; - } +final class WP_CSS_Class_Selector implements IWP_CSS_Selector_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + return (bool) $processor->has_class( $this->ident ); + } - $offset = $updated_offset; - return new self( $result ); + /** @var string */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; } } -final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Type_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { + $tag_name = $processor->get_tag(); + if ( null === $tag_name ) { + return false; + } if ( '*' === $this->ident ) { return true; } - return 0 === strcasecmp( $processor->get_tag(), $this->ident ); + return 0 === strcasecmp( $tag_name, $this->ident ); } /** @@ -659,44 +967,12 @@ public function matches( WP_HTML_Processor $processor ): bool { */ public $ident; - private function __construct( string $ident ) { + public function __construct( string $ident ) { $this->ident = $ident; } - - /** - * Parse a type selector - * - * > = | ? '*' - * > = [ | '*' ]? '|' - * > = ? - * - * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, - * so this selector effectively matches * or ident. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - if ( '*' === $input[ $offset ] ) { - ++$offset; - return new self( '*' ); - } - - $result = self::parse_ident( $input, $offset ); - if ( null === $result ) { - return null; - } - - return new self( $result ); - } } -final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Attribute_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { $att_value = $processor->get_attribute( $this->name ); if ( null === $att_value ) { @@ -772,17 +1048,17 @@ public function matches( WP_HTML_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); + $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. - $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); + $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); if ( 0 === $length ) { return; } $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); + $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; } @@ -877,137 +1153,12 @@ private function whitespace_delimited_list( string $input ): Generator { * @param null|string $value * @param null|self::MODIFIER_* $modifier */ - private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; $this->matcher = $matcher; $this->value = $value; $this->modifier = $modifier; } - - /** - * Parse a attribute selector - * - * > = '[' ']' | - * > '[' [ | ] ? ']' - * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * > = i | s - * > = ? - * - * Namespaces are not supported, so attribute names are effectively identifiers. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - // Need at least 3 bytes [x] - if ( $offset + 2 >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - - if ( '[' !== $input[ $updated_offset ] ) { - return null; - } - ++$updated_offset; - - self::parse_whitespace( $input, $updated_offset ); - $attr_name = self::parse_ident( $input, $updated_offset ); - if ( null === $attr_name ) { - return null; - } - self::parse_whitespace( $input, $updated_offset ); - - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new self( $attr_name ); - } - - // need to match at least `=x]` at this point - if ( $updated_offset + 3 >= strlen( $input ) ) { - return null; - } - - if ( '=' === $input[ $updated_offset ] ) { - ++$updated_offset; - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; - } elseif ( '=' === $input[ $updated_offset + 1 ] ) { - switch ( $input[ $updated_offset ] ) { - case '~': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; - $updated_offset += 2; - break; - case '|': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; - $updated_offset += 2; - break; - case '^': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; - $updated_offset += 2; - break; - case '$': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; - $updated_offset += 2; - break; - case '*': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; - $updated_offset += 2; - break; - default: - return null; - } - } else { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - $attr_val = - self::parse_string( $input, $updated_offset ) ?? - self::parse_ident( $input, $updated_offset ); - - if ( null === $attr_val ) { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - $attr_modifier = null; - switch ( $input[ $updated_offset ] ) { - case 'i': - case 'I': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; - ++$updated_offset; - break; - - case 's': - case 'S': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; - ++$updated_offset; - break; - } - - if ( null !== $attr_modifier ) { - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); - } - - return null; - } } /** @@ -1015,7 +1166,7 @@ public static function parse( string $input, int &$offset ): ?self { * * > = [ ? * ]! */ -final class WP_CSS_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Compound_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { @@ -1042,65 +1193,18 @@ public function matches( WP_HTML_Processor $processor ): bool { * @param WP_CSS_Type_Selector|null $type_selector * @param array $subclass_selectors */ - private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { $this->type_selector = $type_selector; $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; } - - /** - * > = [ ? * ]! - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $type_selector = WP_CSS_Type_Selector::parse( $input, $updated_offset ); - - $subclass_selectors = array(); - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - while ( null !== $last_parsed_subclass_selector ) { - $subclass_selectors[] = $last_parsed_subclass_selector; - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - } - - if ( null !== $type_selector || array() !== $subclass_selectors ) { - $offset = $updated_offset; - return new self( $type_selector, $subclass_selectors ); - } - return null; - } - - /** - * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null - */ - private static function parse_subclass_selector( string $input, int &$offset ) { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $next_char = $input[ $offset ]; - return '.' === $next_char - ? WP_CSS_Class_Selector::parse( $input, $offset ) - : ( - '#' === $next_char - ? WP_CSS_ID_Selector::parse( $input, $offset ) - : ( '[' === $next_char - ? WP_CSS_Attribute_Selector::parse( $input, $offset ) - : null - ) - ); - } } - /** * This corresponds to in the grammar. * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Complex_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { @@ -1120,7 +1224,7 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array $selectors * @param array $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { @@ -1133,7 +1237,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { /** @var self::COMBINATOR_* $combinator */ $combinator = $selectors[0]; - /** @var WP_CSS_Selector $selector */ + /** @var WP_CSS_Compound_Selector $selector */ $selector = $selectors[1]; switch ( $combinator ) { @@ -1166,78 +1270,18 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { const COMBINATOR_SUBSEQUENT_SIBLING = '~'; /** - * even indexes are WP_CSS_Selector, odd indexes are string combinators. + * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. * In reverse order to match the current element and then work up the tree. * Any non-final selector is a type selector. * - * @var array + * @var array */ public $selectors = array(); /** - * @param array $selectors + * @param array $selectors */ - private function __construct( array $selectors ) { + public function __construct( array $selectors ) { $this->selectors = array_reverse( $selectors ); } - - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - if ( null === $selector ) { - return null; - } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - while ( $updated_offset < strlen( $input ) ) { - if ( - self::COMBINATOR_CHILD === $input[ $updated_offset ] || - self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || - self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] - ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // Failure to find a selector here is a parse error - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - } elseif ( $found_whitespace ) { - /* - * Whitespace is ambiguous, it could be a descendant combinator or - * insignificant whitespace. - */ - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - if ( null === $selector ) { - break; - } - $combinator = self::COMBINATOR_DESCENDANT; - } else { - break; - } - - if ( null === $selector ) { - return null; - } - - // `div > .className` is valid, but `.className > div` is not. - if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); - } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $selectors[] = $combinator; - $selectors[] = $selector; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - } - $offset = $updated_offset; - return new self( $selectors ); - } } diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 438dee4c47f4e..bee0f63824abd 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -642,7 +642,7 @@ public function get_unsupported_exception() { * @return Generator|null */ public function select_all( string $selectors ): ?Generator { - $select = WP_CSS_Selector_List::from_selectors( $selectors ); + $select = WP_CSS_Selector::from_selectors( $selectors ); if ( null === $select ) { return null; } diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 5983f91c5d9ba..19c1595253d84 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -11,6 +11,63 @@ * @group html-api */ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { + private $test_class; + + public function set_up(): void { + parent::set_up(); + $this->test_class = new class() extends WP_CSS_Selector { + public function __construct() { + parent::__construct( array() ); + } + + /* + * Parsing + */ + public static function test_parse_ident( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + + public static function test_parse_string( string $input, int &$offset ) { + return self::parse_string( $input, $offset ); + } + + public static function test_parse_type_selector( string $input, int &$offset ) { + return self::parse_type_selector( $input, $offset ); + } + + public static function test_parse_id_selector( string $input, int &$offset ) { + return self::parse_id_selector( $input, $offset ); + } + + public static function test_parse_class_selector( string $input, int &$offset ) { + return self::parse_class_selector( $input, $offset ); + } + + public static function test_parse_attribute_selector( string $input, int &$offset ) { + return self::parse_attribute_selector( $input, $offset ); + } + + public static function test_parse_compound_selector( string $input, int &$offset ) { + return self::parse_compound_selector( $input, $offset ); + } + + public static function test_parse_complex_selector( string $input, int &$offset ) { + return self::parse_complex_selector( $input, $offset ); + } + + /* + * Utilities + */ + public static function test_is_ident_codepoint( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start_codepoint( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + } + /** * Data provider. * @@ -64,22 +121,10 @@ public static function data_idents(): array { * @ticket TBD */ public function test_is_ident_and_is_ident_start() { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - - public static function test_is_ident( string $input, int $offset ) { - return self::is_ident_codepoint( $input, $offset ); - } - - public static function test_is_ident_start( string $input, int $offset ) { - return self::is_ident_start_codepoint( $input, $offset ); - } - }; - - $this->assertFalse( $c::test_is_ident( '[', 0 ) ); - $this->assertFalse( $c::test_is_ident( ']', 0 ) ); - $this->assertFalse( $c::test_is_ident_start( '[', 0 ) ); - $this->assertFalse( $c::test_is_ident_start( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( ']', 0 ) ); } /** @@ -88,15 +133,9 @@ public static function test_is_ident_start( string $input, int $offset ) { * @dataProvider data_idents */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, &$offset ) { - return self::parse_ident( $input, $offset ); - } - }; $offset = 0; - $result = $c::test( $input, $offset ); + $result = $this->test_class::test_parse_ident( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -111,15 +150,8 @@ public static function test( string $input, &$offset ) { * @dataProvider data_strings */ public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, &$offset ) { - return self::parse_string( $input, $offset ); - } - }; - $offset = 0; - $result = $c::test( $input, $offset ); + $result = $this->test_class::test_parse_string( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -170,7 +202,7 @@ public static function data_strings(): array { */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_ID_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_id_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -204,7 +236,7 @@ public static function data_id_selectors(): array { */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Class_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_class_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -238,7 +270,7 @@ public static function data_class_selectors(): array { */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Type_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_type_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -281,7 +313,7 @@ public function test_parse_attribute( ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Attribute_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_attribute_selector( $input, $offset ); if ( null === $expected_name ) { $this->assertNull( $result ); } else { @@ -347,7 +379,7 @@ public static function data_attribute_selectors(): array { public function test_parse_selector() { $input = 'el.foo#bar[baz=quux] > .child'; $offset = 0; - $sel = WP_CSS_Selector::parse( $input, $offset ); + $sel = $this->test_class::test_parse_compound_selector( $input, $offset ); $this->assertSame( 'el', $sel->type_selector->ident ); $this->assertSame( 3, count( $sel->subclass_selectors ) ); @@ -365,8 +397,9 @@ public function test_parse_selector() { public function test_parse_empty_selector() { $input = ''; $offset = 0; - $result = WP_CSS_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_compound_selector( $input, $offset ); $this->assertNull( $result ); + $this->assertSame( 0, $offset ); } /** @@ -375,7 +408,7 @@ public function test_parse_empty_selector() { public function test_parse_complex_selector() { $input = 'el1 > .child#bar[baz=quux] , rest'; $offset = 0; - $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertSame( 3, count( $sel->selectors ) ); @@ -398,14 +431,14 @@ public function test_parse_complex_selector() { public function test_parse_invalid_complex_selector() { $input = 'el.foo#bar[baz=quux] > , rest'; $offset = 0; - $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertNull( $result ); } public function test_parse_empty_complex_selector() { $input = ''; $offset = 0; - $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertNull( $result ); } @@ -415,7 +448,7 @@ public function test_parse_empty_complex_selector() { */ public function test_parse_selector_list() { $input = 'el1 el2 el.foo#bar[baz=quux], rest'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNotNull( $result ); } @@ -424,7 +457,7 @@ public function test_parse_selector_list() { */ public function test_parse_invalid_selector_list() { $input = 'el,,'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } @@ -433,7 +466,7 @@ public function test_parse_invalid_selector_list() { */ public function test_parse_invalid_selector_list2() { $input = 'el!'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } @@ -442,7 +475,7 @@ public function test_parse_invalid_selector_list2() { */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } } From 6a6969f435d659f9fc26c208faf4495c18c60278 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 18:22:35 +0100 Subject: [PATCH 070/336] Rename files to align with class name --- .../{class-wp-css-selectors.php => class-wp-css-selector.php} | 0 src/wp-settings.php | 2 +- .../html-api/{wpCssSelectors.php => wpCssSelector-parsing.php} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/wp-includes/html-api/{class-wp-css-selectors.php => class-wp-css-selector.php} (100%) rename tests/phpunit/tests/html-api/{wpCssSelectors.php => wpCssSelector-parsing.php} (99%) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-selectors.php rename to src/wp-includes/html-api/class-wp-css-selector.php diff --git a/src/wp-settings.php b/src/wp-settings.php index 6c799d5c95140..cfdd9234b7003 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -265,7 +265,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-selectors.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-selector.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.php'; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelector-parsing.php similarity index 99% rename from tests/phpunit/tests/html-api/wpCssSelectors.php rename to tests/phpunit/tests/html-api/wpCssSelector-parsing.php index 19c1595253d84..4caa186158149 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelector-parsing.php @@ -10,7 +10,7 @@ * * @group html-api */ -class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { +class Tests_HtmlApi_WpCssSelector_Parsing extends WP_UnitTestCase { private $test_class; public function set_up(): void { From 27ca891846d35f6d18f0b0031147ece99bd11d9e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 21:00:08 +0100 Subject: [PATCH 071/336] Add html processor select test suite --- .../tests/html-api/wpHtmlProcessor-select.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/phpunit/tests/html-api/wpHtmlProcessor-select.php diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php new file mode 100644 index 0000000000000..e70dedcfcd3c4 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -0,0 +1,68 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket TBD + * + * @dataProvider data_selectors + */ + public function test_select( string $html, string $selector ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $this->assertTrue( $processor->select( $selector ) ); + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'simple type' => array( '
', 'div' ), + 'any type' => array( '', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'simple attribute' => array( '
', '[att]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + 'complex any descendant' => array( '
', 'section *' ), + 'complex any child' => array( '
', 'section > *' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att~="bar"]' ), + ); + } + + /** + * @ticket TBD + */ + public function test_select_all() { + $processor = WP_HTML_Processor::create_full_parser( '

' ); + $count = 0; + foreach ( $processor->select_all( 'div, .x, svg>rect, #y' ) as $_ ) { + ++$count; + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + $this->assertSame( 4, $count ); + } +} From 9ff276965a60f3a7ccd89facc67cc9d4b267d90e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 21:00:30 +0100 Subject: [PATCH 072/336] Fix select types --- src/wp-includes/html-api/class-wp-html-processor.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index bee0f63824abd..23ca6edc4ff7e 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -638,13 +638,15 @@ public function get_unsupported_exception() { /** * Use a selector to advance. * + * @todo _doing_it_wrong on null selector? + * * @param string $selectors - * @return Generator|null + * @return Generator */ public function select_all( string $selectors ): ?Generator { $select = WP_CSS_Selector::from_selectors( $selectors ); if ( null === $select ) { - return null; + return; } while ( $this->next_tag() ) { @@ -660,13 +662,10 @@ public function select_all( string $selectors ): ?Generator { * If iterating through matching elements, use `select_all` instead. * * @param string $selectors - * @return bool|null + * @return bool */ public function select( string $selectors ) { $selection = $this->select_all( $selectors ); - if ( null === $selection ) { - return null; - } foreach ( $selection as $_ ) { return true; } From d1a276b848ef8b9b5f954641ed762ad3d591b2cb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 13:55:57 +0100 Subject: [PATCH 073/336] Update class doc --- src/wp-includes/html-api/class-wp-css-selector.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 7588eb72294bd..c27c81593059d 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -23,8 +23,7 @@ * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. * - * @todo Review this grammar, especially the complex selector for accurate support information. - * The supported grammar is: + * This class is rougly analogous to the in the grammar. The supported grammar is: * * = * = # @@ -43,6 +42,7 @@ * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * + * Note that this grammar has been adapted and does not support the full CSS selector grammar. * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) * - Class selectors (e.g. `.class-name`) @@ -61,11 +61,11 @@ * - Next sibling (`el + el`) * - Subsequent sibling (`el ~ el`) * - * Future ideas - * - Namespace type selectors could be implemented with select namespaces in order to - * select elements from a namespace, for example: - * - `svg|*` to select all SVG elements - * - `html|title` to select only HTML TITLE elements. + * Future ideas: + * - Namespace type selectors could be implemented with select namespaces in order to + * select elements from a namespace, for example: + * - `svg|*` to select all SVG elements + * - `html|title` to select only HTML TITLE elements. * * @since TBD * From 4909b569c067ab556e81b0cbcce087d3d1867676 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 16:00:36 +0100 Subject: [PATCH 074/336] Improve select_ method arguments, docs, implementation --- .../html-api/class-wp-html-processor.php | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 23ca6edc4ff7e..398c5c4fd096c 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -636,37 +636,64 @@ public function get_unsupported_exception() { } /** - * Use a selector to advance. + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } * - * @todo _doing_it_wrong on null selector? + * @since TBD * - * @param string $selectors - * @return Generator + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selectors ): ?Generator { - $select = WP_CSS_Selector::from_selectors( $selectors ); - if ( null === $select ) { + public function select_all( string $selector_string ): ?Generator { + $selector = WP_CSS_Selector::from_selectors( $selector_string ); + if ( null === $selector ) { return; } while ( $this->next_tag() ) { - if ( $select->matches( $this ) ) { + if ( $selector->matches( $this ) ) { yield; } } } /** - * Select the next matching element. + * Move to the next tag matching the provided CSS selector string. * - * If iterating through matching elements, use `select_all` instead. + * This method will stop at the next match. To progress through all matches, use + * the `select_all` method. * - * @param string $selectors - * @return bool + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. */ - public function select( string $selectors ) { - $selection = $this->select_all( $selectors ); - foreach ( $selection as $_ ) { + public function select( string $selector_string ) { + foreach ( $this->select_all( $selector_string ) as $_ ) { return true; } return false; From 1d45225e46b85b2e8e9f8091cf9aefac3c46c2eb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:08:58 +0100 Subject: [PATCH 075/336] Split classes into their own files Satisfy the 1-class-per-file requirement --- .../class-wp-css-attribute-selector.php | 190 +++++++++ .../html-api/class-wp-css-class-selector.php | 14 + .../class-wp-css-complex-selector.php | 88 ++++ .../class-wp-css-compound-selector.php | 39 ++ .../html-api/class-wp-css-id-selector.php | 22 + .../html-api/class-wp-css-selector.php | 389 +----------------- .../html-api/class-wp-css-type-selector.php | 25 ++ ...nterface-wp-css-html-processor-matcher.php | 8 + src/wp-settings.php | 7 + 9 files changed, 396 insertions(+), 386 deletions(-) create mode 100644 src/wp-includes/html-api/class-wp-css-attribute-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-class-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-complex-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-compound-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-id-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-type-selector.php create mode 100644 src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php new file mode 100644 index 0000000000000..be7332c85b72d --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -0,0 +1,190 @@ +get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + if ( true === $att_value ) { + $att_value = ''; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) + ); + } + + throw new Exception( 'Unreachable' ); + } + + /** + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); + if ( 0 === $length ) { + return; + } + + $value = substr( $input, $offset, $length ); + $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); + + yield $value; + } + } + + /** + * [att=val] + * Represents an element with the att attribute whose value is exactly "val". + */ + const MATCH_EXACT = 'MATCH_EXACT'; + + /** + * [attr~=value] + * Represents elements with an attribute name of attr whose value is a + * whitespace-separated list of words, one of which is exactly value. + */ + const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; + + /** + * [attr|=value] + * Represents elements with an attribute name of attr whose value can be exactly value or + * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for + * language subcode matches. + */ + const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; + + /** + * [attr^=value] + * Represents elements with an attribute name of attr whose value is prefixed (preceded) + * by value. + */ + const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; + + /** + * [attr$=value] + * Represents elements with an attribute name of attr whose value is suffixed (followed) + * by value. + */ + const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; + + /** + * [attr*=value] + * Represents elements with an attribute name of attr whose value contains at least one + * occurrence of value within the string. + */ + const MATCH_CONTAINS = 'MATCH_CONTAINS'; + + /** + * Modifier for case sensitive matching + * [attr=value s] + */ + const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; + + /** + * Modifier for case insensitive matching + * [attr=value i] + */ + const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; + + + /** + * The attribute name. + * + * @var string + */ + public $name; + + /** + * The attribute matcher. + * + * @var null|self::MATCH_* + */ + public $matcher; + + /** + * The attribute value. + * + * @var string|null + */ + public $value; + + /** + * The attribute modifier. + * + * @var null|self::MODIFIER_* + */ + public $modifier; + + /** + * @param string $name + * @param null|self::MATCH_* $matcher + * @param null|string $value + * @param null|self::MODIFIER_* $modifier + */ + public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + $this->name = $name; + $this->matcher = $matcher; + $this->value = $value; + $this->modifier = $modifier; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php new file mode 100644 index 0000000000000..c4f858d4a05d9 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -0,0 +1,14 @@ +has_class( $this->ident ); + } + + /** @var string */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php new file mode 100644 index 0000000000000..520f3bf3d8fde --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -0,0 +1,88 @@ + in the grammar. + * + * > = [ ? ] * + */ +final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + // First selector must match this location. + if ( ! $this->selectors[0]->matches( $processor ) ) { + return false; + } + + if ( count( $this->selectors ) === 1 ) { + return true; + } + + /** @var array $breadcrumbs */ + $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); + $selectors = array_slice( $this->selectors, 1 ); + return $this->explore_matches( $selectors, $breadcrumbs ); + } + + /** + * This only looks at breadcrumbs and can therefore only support type selectors. + * + * @param array $selectors + * @param array $breadcrumbs + */ + private function explore_matches( array $selectors, array $breadcrumbs ): bool { + if ( array() === $selectors ) { + return true; + } + if ( array() === $breadcrumbs ) { + return false; + } + + /** @var self::COMBINATOR_* $combinator */ + $combinator = $selectors[0]; + /** @var WP_CSS_Compound_Selector $selector */ + $selector = $selectors[1]; + + switch ( $combinator ) { + case self::COMBINATOR_CHILD: + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + } + return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + + case self::COMBINATOR_DESCENDANT: + // Find _all_ the breadcrumbs that match and recurse from each of them. + for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + return true; + } + } + } + return false; + + default: + throw new Exception( "Combinator '{$combinator}' is not supported yet." ); + } + } + + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. + * In reverse order to match the current element and then work up the tree. + * Any non-final selector is a type selector. + * + * @var array + */ + public $selectors = array(); + + /** + * @param array $selectors + */ + public function __construct( array $selectors ) { + $this->selectors = array_reverse( $selectors ); + } +} diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php new file mode 100644 index 0000000000000..1162aaef78c1e --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -0,0 +1,39 @@ + in the grammar. + * + * > = [ ? * ]! + */ +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Processor_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $this->type_selector ) { + if ( ! $this->type_selector->matches( $processor ) ) { + return false; + } + } + if ( null !== $this->subclass_selectors ) { + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } + } + } + return true; + } + + /** @var WP_CSS_Type_Selector|null */ + public $type_selector; + + /** @var array|null */ + public $subclass_selectors; + + /** + * @param WP_CSS_Type_Selector|null $type_selector + * @param array $subclass_selectors + */ + public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php new file mode 100644 index 0000000000000..cc0589327c829 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -0,0 +1,22 @@ +ident = $ident; + } + + public function matches( WP_HTML_Processor $processor ): bool { + $id = $processor->get_attribute( 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + return $case_insensitive + ? 0 === strcasecmp( $id, $this->ident ) + : $processor->get_attribute( 'id' ) === $this->ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index c27c81593059d..b776bad66146b 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -1,10 +1,6 @@ -get_token_type() !== '#tag' ) { return false; @@ -906,382 +902,3 @@ private static function check_if_three_code_points_would_start_an_ident_sequence return self::is_ident_start_codepoint( $input, $offset ); } } - -interface IWP_CSS_Selector_Matcher { - /** - * @return bool - */ - public function matches( WP_HTML_Processor $processor ): bool; -} - -final class WP_CSS_ID_Selector implements IWP_CSS_Selector_Matcher { - /** @var string */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } - - public function matches( WP_HTML_Processor $processor ): bool { - $id = $processor->get_attribute( 'id' ); - if ( ! is_string( $id ) ) { - return false; - } - - $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); - return $case_insensitive - ? 0 === strcasecmp( $id, $this->ident ) - : $processor->get_attribute( 'id' ) === $this->ident; - } -} - -final class WP_CSS_Class_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - return (bool) $processor->has_class( $this->ident ); - } - - /** @var string */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } -} - -final class WP_CSS_Type_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - $tag_name = $processor->get_tag(); - if ( null === $tag_name ) { - return false; - } - if ( '*' === $this->ident ) { - return true; - } - return 0 === strcasecmp( $tag_name, $this->ident ); - } - - /** - * @var string - * - * The type identifier string or '*'. - */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } -} - -final class WP_CSS_Attribute_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - $att_value = $processor->get_attribute( $this->name ); - if ( null === $att_value ) { - return false; - } - - if ( null === $this->value ) { - return true; - } - - if ( true === $att_value ) { - $att_value = ''; - } - - $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; - - switch ( $this->matcher ) { - case self::MATCH_EXACT: - return $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value; - - case self::MATCH_ONE_OF_EXACT: - foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { - if ( - $case_insensitive - ? 0 === strcasecmp( $val, $this->value ) - : $val === $this->value - ) { - return true; - } - } - return false; - - case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: - // Attempt the full match first - if ( - $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value - ) { - return true; - } - - // Partial match - if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { - return false; - } - - $starts_with = "{$this->value}-"; - return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); - - case self::MATCH_PREFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); - - case self::MATCH_SUFFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); - - case self::MATCH_CONTAINS: - return false !== ( - $case_insensitive - ? stripos( $att_value, $this->value ) - : strpos( $att_value, $this->value ) - ); - } - - throw new Exception( 'Unreachable' ); - } - - /** - * @param string $input - * - * @return Generator - */ - private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); - - while ( $offset < strlen( $input ) ) { - // Find the byte length until the next boundary. - $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); - if ( 0 === $length ) { - return; - } - - $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); - - yield $value; - } - } - - /** - * [att=val] - * Represents an element with the att attribute whose value is exactly "val". - */ - const MATCH_EXACT = 'MATCH_EXACT'; - - /** - * [attr~=value] - * Represents elements with an attribute name of attr whose value is a - * whitespace-separated list of words, one of which is exactly value. - */ - const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; - - /** - * [attr|=value] - * Represents elements with an attribute name of attr whose value can be exactly value or - * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for - * language subcode matches. - */ - const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; - - /** - * [attr^=value] - * Represents elements with an attribute name of attr whose value is prefixed (preceded) - * by value. - */ - const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; - - /** - * [attr$=value] - * Represents elements with an attribute name of attr whose value is suffixed (followed) - * by value. - */ - const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; - - /** - * [attr*=value] - * Represents elements with an attribute name of attr whose value contains at least one - * occurrence of value within the string. - */ - const MATCH_CONTAINS = 'MATCH_CONTAINS'; - - /** - * Modifier for case sensitive matching - * [attr=value s] - */ - const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; - - /** - * Modifier for case insensitive matching - * [attr=value i] - */ - const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; - - - /** - * The attribute name. - * - * @var string - */ - public $name; - - /** - * The attribute matcher. - * - * @var null|self::MATCH_* - */ - public $matcher; - - /** - * The attribute value. - * - * @var string|null - */ - public $value; - - /** - * The attribute modifier. - * - * @var null|self::MODIFIER_* - */ - public $modifier; - - /** - * @param string $name - * @param null|self::MATCH_* $matcher - * @param null|string $value - * @param null|self::MODIFIER_* $modifier - */ - public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { - $this->name = $name; - $this->matcher = $matcher; - $this->value = $value; - $this->modifier = $modifier; - } -} - -/** - * This corresponds to in the grammar. - * - * > = [ ? * ]! - */ -final class WP_CSS_Compound_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - if ( $this->type_selector ) { - if ( ! $this->type_selector->matches( $processor ) ) { - return false; - } - } - if ( null !== $this->subclass_selectors ) { - foreach ( $this->subclass_selectors as $subclass_selector ) { - if ( ! $subclass_selector->matches( $processor ) ) { - return false; - } - } - } - return true; - } - - /** @var WP_CSS_Type_Selector|null */ - public $type_selector; - - /** @var array|null */ - public $subclass_selectors; - - /** - * @param WP_CSS_Type_Selector|null $type_selector - * @param array $subclass_selectors - */ - public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { - $this->type_selector = $type_selector; - $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; - } -} - -/** - * This corresponds to in the grammar. - * - * > = [ ? ] * - */ -final class WP_CSS_Complex_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - // First selector must match this location. - if ( ! $this->selectors[0]->matches( $processor ) ) { - return false; - } - - if ( count( $this->selectors ) === 1 ) { - return true; - } - - /** @var array $breadcrumbs */ - $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); - $selectors = array_slice( $this->selectors, 1 ); - return $this->explore_matches( $selectors, $breadcrumbs ); - } - - /** - * This only looks at breadcrumbs and can therefore only support type selectors. - * - * @param array $selectors - * @param array $breadcrumbs - */ - private function explore_matches( array $selectors, array $breadcrumbs ): bool { - if ( array() === $selectors ) { - return true; - } - if ( array() === $breadcrumbs ) { - return false; - } - - /** @var self::COMBINATOR_* $combinator */ - $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector $selector */ - $selector = $selectors[1]; - - switch ( $combinator ) { - case self::COMBINATOR_CHILD: - if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { - return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); - } - return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); - - case self::COMBINATOR_DESCENDANT: - // Find _all_ the breadcrumbs that match and recurse from each of them. - for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { - if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { - $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); - if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { - return true; - } - } - } - return false; - - default: - throw new Exception( "Combinator '{$combinator}' is not supported yet." ); - } - } - - const COMBINATOR_CHILD = '>'; - const COMBINATOR_DESCENDANT = ' '; - const COMBINATOR_NEXT_SIBLING = '+'; - const COMBINATOR_SUBSEQUENT_SIBLING = '~'; - - /** - * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. - * In reverse order to match the current element and then work up the tree. - * Any non-final selector is a type selector. - * - * @var array - */ - public $selectors = array(); - - /** - * @param array $selectors - */ - public function __construct( array $selectors ) { - $this->selectors = array_reverse( $selectors ); - } -} diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php new file mode 100644 index 0000000000000..a2dcd16521cb5 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -0,0 +1,25 @@ +get_tag(); + if ( null === $tag_name ) { + return false; + } + if ( '*' === $this->ident ) { + return true; + } + return 0 === strcasecmp( $tag_name, $this->ident ); + } + + /** + * @var string + * + * The type identifier string or '*'. + */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php new file mode 100644 index 0000000000000..2ae29413b35d2 --- /dev/null +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -0,0 +1,8 @@ + Date: Wed, 4 Dec 2024 18:09:17 +0100 Subject: [PATCH 076/336] Remove redundant see phpdoc annotations --- src/wp-includes/html-api/class-wp-css-selector.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index b776bad66146b..487c100ab47e4 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -67,11 +67,10 @@ * * @access private * - * @see {@link https://www.w3.org/TR/css-syntax-3/} - * @see {@link https://www.w3.org/tr/selectors/} - * @see {@link https://www.w3.org/TR/selectors-api2/} - * @see {@link https://www.w3.org/TR/selectors-4/} - * + * @link https://www.w3.org/TR/css-syntax-3/ + * @link https://www.w3.org/tr/selectors/ + * @link https://www.w3.org/TR/selectors-api2/ + * @link https://www.w3.org/TR/selectors-4/ */ class WP_CSS_Selector implements WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { From 0c53c422de2f40206b9322f8f0ae3beaf85b5e4b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:28:54 +0100 Subject: [PATCH 077/336] Fix docs and return type on select_all --- src/wp-includes/html-api/class-wp-html-processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 398c5c4fd096c..9f7a43acaebbd 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -657,7 +657,7 @@ public function get_unsupported_exception() { * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selector_string ): ?Generator { + public function select_all( string $selector_string ): Generator { $selector = WP_CSS_Selector::from_selectors( $selector_string ); if ( null === $selector ) { return; @@ -674,7 +674,7 @@ public function select_all( string $selector_string ): ?Generator { * Move to the next tag matching the provided CSS selector string. * * This method will stop at the next match. To progress through all matches, use - * the `select_all` method. + * the {@see WP_HTML_Processor::select_all()} method. * * @example * From d966e9ad7fdc9270fded62abb9e32923ced79d61 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:31:05 +0100 Subject: [PATCH 078/336] Improve html select test docs --- tests/phpunit/tests/html-api/wpHtmlProcessor-select.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index e70dedcfcd3c4..c3a1e4121ecab 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -1,6 +1,9 @@ Date: Wed, 4 Dec 2024 19:40:23 +0100 Subject: [PATCH 079/336] Add select support to tag processor Split up main CSS selector class and support more restricted selectors in the tag processor. --- .../class-wp-css-attribute-selector.php | 12 +- .../html-api/class-wp-css-class-selector.php | 4 +- .../class-wp-css-complex-selector-list.php | 165 ++++++++++++++++++ ...> class-wp-css-compound-selector-list.php} | 126 ++++--------- .../class-wp-css-compound-selector.php | 4 +- .../html-api/class-wp-css-id-selector.php | 7 +- .../html-api/class-wp-css-type-selector.php | 4 +- .../html-api/class-wp-html-processor.php | 11 +- .../html-api/class-wp-html-tag-processor.php | 69 ++++++++ ...face-wp-css-html-tag-processor-matcher.php | 8 + src/wp-settings.php | 4 +- .../html-api/wpCssComplexSelectorList.php | 107 ++++++++++++ ...sing.php => wpCssCompoundSelectorList.php} | 59 +------ .../tests/html-api/wpHtmlProcessor-select.php | 10 ++ .../html-api/wpHtmlTagProcessor-select.php | 92 ++++++++++ 15 files changed, 520 insertions(+), 162 deletions(-) create mode 100644 src/wp-includes/html-api/class-wp-css-complex-selector-list.php rename src/wp-includes/html-api/{class-wp-css-selector.php => class-wp-css-compound-selector-list.php} (87%) create mode 100644 src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php create mode 100644 tests/phpunit/tests/html-api/wpCssComplexSelectorList.php rename tests/phpunit/tests/html-api/{wpCssSelector-parsing.php => wpCssCompoundSelectorList.php} (89%) create mode 100644 tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index be7332c85b72d..76ccdf3804b36 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,7 +1,9 @@ get_attribute( $this->name ); if ( null === $att_value ) { return false; @@ -76,17 +78,17 @@ public function matches( WP_HTML_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); + $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. - $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); + $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); if ( 0 === $length ) { return; } $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); + $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; } diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index c4f858d4a05d9..c3e7ced008a6e 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -1,7 +1,7 @@ has_class( $this->ident ); } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php new file mode 100644 index 0000000000000..f3769a035f6e5 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -0,0 +1,165 @@ + in the grammar. See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. + * + * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as: + * - The following combinators: + * - Next sibling (`el + el`) + * - Subsequent sibling (`el ~ el`) + * + * @since TBD + * + * @access private + */ +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { + /** + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. + * + * @since TBD + * + * @param string $input CSS selectors. + * @return static|null + */ + public static function from_selectors( string $input ) { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + $input = trim( $input, " \t\r\n\r" ); + + if ( '' === $input ) { + return null; + } + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + $offset = 0; + + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + self::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + self::parse_whitespace( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + self::parse_whitespace( $input, $offset ); + } + + return new self( $selectors ); + } + + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + + /** + * Parses a complex selector. + * + * > = [ ? ]* + * + * @return WP_CSS_Complex_Selector|null + */ + final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + + // Failure to find a selector here is a parse error + $selector = self::parse_compound_selector( $input, $updated_offset ); + } elseif ( $found_whitespace ) { + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + break; + } + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; + } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new WP_CSS_Complex_Selector( $selectors ); + } +} diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php similarity index 87% rename from src/wp-includes/html-api/class-wp-css-selector.php rename to src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 487c100ab47e4..2aae51d671f6b 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -1,6 +1,6 @@ in the grammar. The supported grammar is: + * This class is analogous to in the grammar. The supported grammar is: * * = * = # @@ -38,6 +40,10 @@ * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * + * This class of selectors does not support "complex" selectors. That is any selector with a + * combinator such as descendent (`.ancestor .descendant`) or child (`.parent > .child`). + * See {@see WP_CSS_Complex_Selector_List} for support of some combinators. + * * Note that this grammar has been adapted and does not support the full CSS selector grammar. * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) @@ -50,12 +56,10 @@ * - child (`el > .child`) * * Unsupported selector syntax: - * - Pseudo-element selectors (e.g. `::before`) - * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) - * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) - * - The following combinators: - * - Next sibling (`el + el`) - * - Subsequent sibling (`el ~ el`) + * - Pseudo-element selectors (`::before`) + * - Pseudo-class selectors (`:hover` or `:nth-child(2)`) + * - Namespace prefixes (`svg|title` or `[xlink|href]`) + * - No combinators are supported (descendant, child, next sibling, subsequent sibling) * * Future ideas: * - Namespace type selectors could be implemented with select namespaces in order to @@ -72,8 +76,12 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Selector implements WP_CSS_HTML_Processor_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { +class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { + /** + * @param WP_HTML_Tag_Processor $processor + * @return bool + */ + public function matches( $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; } @@ -87,14 +95,16 @@ public function matches( WP_HTML_Processor $processor ): bool { } /** - * @var array + * Array of selectors. + * + * @var array */ private $selectors; /** * Constructor. * - * @param array $selectors + * @param array $selectors Array of selectors. */ protected function __construct( array $selectors ) { $this->selectors = $selectors; @@ -107,10 +117,9 @@ protected function __construct( array $selectors ) { * @since TBD * * @param string $input CSS selectors. - * @return self|null + * @return static|null */ - public static function from_selectors( string $input ): ?self { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + public static function from_selectors( string $input ) { $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { @@ -132,7 +141,7 @@ public static function from_selectors( string $input ): ?self { $offset = 0; - $selector = self::parse_complex_selector( $input, $offset ); + $selector = self::parse_compound_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -146,7 +155,7 @@ public static function from_selectors( string $input ): ?self { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = self::parse_complex_selector( $input, $offset ); + $selector = self::parse_compound_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -391,73 +400,6 @@ final protected static function parse_compound_selector( string $input, int &$of return null; } - /** - * Parses a complex selector. - * - * > = [ ? ]* - * - * @return WP_CSS_Complex_Selector|null - */ - final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { - return null; - } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - while ( $updated_offset < strlen( $input ) ) { - if ( - WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] - ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // Failure to find a selector here is a parse error - $selector = self::parse_compound_selector( $input, $updated_offset ); - } elseif ( $found_whitespace ) { - /* - * Whitespace is ambiguous, it could be a descendant combinator or - * insignificant whitespace. - */ - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { - break; - } - $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; - } else { - break; - } - - if ( null === $selector ) { - return null; - } - - // `div > .className` is valid, but `.className > div` is not. - if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); - } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $selectors[] = $combinator; - $selectors[] = $selector; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - } - $offset = $updated_offset; - return new WP_CSS_Complex_Selector( $selectors ); - } - /** * Parses a subclass selector. * @@ -496,7 +438,7 @@ private static function parse_subclass_selector( string $input, int &$offset ) { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; - public static function parse_whitespace( string $input, int &$offset ): bool { + final public static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $advanced = $length > 0; $offset += $length; @@ -692,9 +634,9 @@ final protected static function parse_string( string $input, int &$offset ): ?st * * @param string $input * @param int $offset - * @return string|null + * @return string */ - final protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + final protected static function consume_escaped_codepoint( $input, &$offset ): string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); if ( $hex_length > 0 ) { /** @@ -771,7 +713,7 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): ? * @param int $offset The byte offset in the string. * @return bool True if the next two codepoints are a valid escape, otherwise false. */ - private static function next_two_are_valid_escape( string $input, int $offset ): bool { + final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -858,7 +800,7 @@ final protected static function is_ident_codepoint( string $input, int $offset ) * @param int $offset The byte offset in the string. * @return bool True if the next three codepoints would start an ident sequence, otherwise false. */ - private static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + final protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 1162aaef78c1e..e64695abe9ab3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -5,8 +5,8 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Processor_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { + public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { return false; diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index cc0589327c829..83339ff839317 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ ident = $ident; } - public function matches( WP_HTML_Processor $processor ): bool { + public function matches( WP_HTML_Tag_Processor $processor ): bool { $id = $processor->get_attribute( 'id' ); if ( ! is_string( $id ) ) { return false; } - $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + $case_insensitive = $processor->is_quirks_mode(); + return $case_insensitive ? 0 === strcasecmp( $id, $this->ident ) : $processor->get_attribute( 'id' ) === $this->ident; diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index a2dcd16521cb5..c65adce14047d 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -1,7 +1,7 @@ get_tag(); if ( null === $tag_name ) { return false; diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 9f7a43acaebbd..bbca730279876 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -657,9 +657,14 @@ public function get_unsupported_exception() { * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selector_string ): Generator { - $selector = WP_CSS_Selector::from_selectors( $selector_string ); + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); return; } @@ -692,7 +697,7 @@ public function select_all( string $selector_string ): Generator { * @param string $selector_string * @return bool True if a matching tag was found, otherwise false. */ - public function select( string $selector_string ) { + public function select( string $selector_string ): bool { foreach ( $this->select_all( $selector_string ) as $_ ) { return true; } 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 7dadbc1bebdb2..a7633291b6bb2 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 @@ -860,6 +860,75 @@ public function change_parsing_namespace( string $new_namespace ): bool { return true; } + /** + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } + * + * @since TBD + * + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. + */ + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); + return; + } + + while ( $this->next_tag() ) { + if ( $selector->matches( $this ) ) { + yield; + } + } + } + + /** + * Move to the next tag matching the provided CSS selector string. + * + * This method will stop at the next match. To progress through all matches, use + * the {@see WP_HTML_Tag_Processor::select_all()} method. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. + */ + public function select( string $selector_string ): bool { + foreach ( $this->select_all( $selector_string ) as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * diff --git a/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php new file mode 100644 index 0000000000000..73d108150bb95 --- /dev/null +++ b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php @@ -0,0 +1,8 @@ +test_class = new class() extends WP_CSS_Complex_Selector_List { + public function __construct() { + parent::__construct( array() ); + } + + public static function test_parse_complex_selector( string $input, int &$offset ) { + return self::parse_complex_selector( $input, $offset ); + } + }; + } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector() { + $input = 'el1 > .child#bar[baz=quux] , rest'; + $offset = 0; + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); + + $this->assertSame( 3, count( $sel->selectors ) ); + + $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); + $this->assertNull( $sel->selectors[2]->subclass_selectors ); + + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertNull( $sel->selectors[0]->type_selector ); + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); + + $this->assertSame( ', rest', substr( $input, $offset ) ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector_list() { + $input = 'el1 el2 el.foo#bar[baz=quux], second > selector'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelector-parsing.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php similarity index 89% rename from tests/phpunit/tests/html-api/wpCssSelector-parsing.php rename to tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 4caa186158149..d94b61d49c14e 100644 --- a/tests/phpunit/tests/html-api/wpCssSelector-parsing.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -10,12 +10,12 @@ * * @group html-api */ -class Tests_HtmlApi_WpCssSelector_Parsing extends WP_UnitTestCase { +class Tests_HtmlApi_WpCssCompoundSelectorList extends WP_UnitTestCase { private $test_class; public function set_up(): void { parent::set_up(); - $this->test_class = new class() extends WP_CSS_Selector { + $this->test_class = new class() extends WP_CSS_Compound_Selector_List { public function __construct() { parent::__construct( array() ); } @@ -51,10 +51,6 @@ public static function test_parse_compound_selector( string $input, int &$offset return self::parse_compound_selector( $input, $offset ); } - public static function test_parse_complex_selector( string $input, int &$offset ) { - return self::parse_complex_selector( $input, $offset ); - } - /* * Utilities */ @@ -402,53 +398,12 @@ public function test_parse_empty_selector() { $this->assertSame( 0, $offset ); } - /** - * @ticket TBD - */ - public function test_parse_complex_selector() { - $input = 'el1 > .child#bar[baz=quux] , rest'; - $offset = 0; - $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - - $this->assertSame( 3, count( $sel->selectors ) ); - - $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); - $this->assertNull( $sel->selectors[2]->subclass_selectors ); - - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); - - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertNull( $sel->selectors[0]->type_selector ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); - - $this->assertSame( ', rest', substr( $input, $offset ) ); - } - - /** - * @ticket TBD - */ - public function test_parse_invalid_complex_selector() { - $input = 'el.foo#bar[baz=quux] > , rest'; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - public function test_parse_empty_complex_selector() { - $input = ''; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - /** * @ticket TBD */ public function test_parse_selector_list() { - $input = 'el1 el2 el.foo#bar[baz=quux], rest'; - $result = WP_CSS_Selector::from_selectors( $input ); + $input = 'el1, el2, el.foo#bar[baz=quux]'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNotNull( $result ); } @@ -457,7 +412,7 @@ public function test_parse_selector_list() { */ public function test_parse_invalid_selector_list() { $input = 'el,,'; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } @@ -466,7 +421,7 @@ public function test_parse_invalid_selector_list() { */ public function test_parse_invalid_selector_list2() { $input = 'el!'; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } @@ -475,7 +430,7 @@ public function test_parse_invalid_selector_list2() { */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index c3a1e4121ecab..733a7135f1b17 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -66,4 +66,14 @@ public function test_select_all() { } $this->assertSame( 4, $count ); } + + /** + * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Processor::select_all + */ + public function test_invalid_selector() { + $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); + $this->assertFalse( $processor->select( '[invalid!selector]' ) ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php new file mode 100644 index 0000000000000..c42c69ff0a095 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -0,0 +1,92 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket TBD + * + * @dataProvider data_selectors + */ + public function test_select( string $html, string $selector ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->select( $selector ) ); + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'simple type' => array( '

', 'div' ), + 'any type' => array( '', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'simple attribute' => array( '
', '[att]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att~="bar"]' ), + ); + } + + /** + * @ticket TBD + */ + public function test_select_all() { + $processor = new WP_HTML_Tag_Processor( '

' ); + $count = 0; + foreach ( $processor->select_all( 'div, .x, rect, #y' ) as $_ ) { + ++$count; + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + $this->assertSame( 4, $count ); + } + + /** + * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all + * + * @dataProvider data_invalid_selectors + */ + public function test_invalid_selector( string $selector ) { + $processor = new WP_HTML_Tag_Processor( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_invalid_selectors(): array { + return array( + 'complex descendant' => array( 'div *' ), + 'complex child' => array( 'div > *' ), + 'invalid selector' => array( '[invalid!selector]' ), + ); + } +} From 2036a83f77a419fd1f3df89c7c7a316d4a42d5bb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:36:19 +0100 Subject: [PATCH 080/336] Simplify whitspace splitting function --- .../html-api/class-wp-css-attribute-selector.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 76ccdf3804b36..1a7a9ffb37716 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -78,16 +78,15 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { + // Start by skipping whitespace. $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); - if ( 0 === $length ) { - return; - } + $value = substr( $input, $offset, $length ); - $value = substr( $input, $offset, $length ); + // Move past trailing whitespace. $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; From 3421a4e0d634686fd820db906eb6077503985fe8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:41:15 +0100 Subject: [PATCH 081/336] Remove unreachable code --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 1a7a9ffb37716..17787dd70815b 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -68,8 +68,6 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { : strpos( $att_value, $this->value ) ); } - - throw new Exception( 'Unreachable' ); } /** From 784b2d913cbf469a3847b93a46c9c202f19091b7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:41:25 +0100 Subject: [PATCH 082/336] Add a lot of selector integration tests --- .../html-api/wpHtmlTagProcessor-select.php | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index c42c69ff0a095..66f32f905c04f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -39,16 +39,42 @@ public function test_select( string $html, string $selector ) { */ public static function data_selectors(): array { return array( - 'simple type' => array( '

', 'div' ), - 'any type' => array( '', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'simple attribute' => array( '
', '[att]' ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), + 'simple type' => array( '

', 'div' ), + 'any type' => array( '
', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'boolean attribute' => array( '
', '[att]' ), + 'boolean attribute with string match' => array( '
', '[att=""]' ), - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att~="bar"]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + 'attribute case insensitive' => array( '
', '[att="VAL"i]' ), + 'attribute case sensitive mod' => array( '
', '[att="val"s]' ), + + 'attribute one of' => array( '
', '[att~="b"]' ), + 'attribute one of insensitive' => array( '
', '[att~="b"i]' ), + 'attribute one of mod sensitive' => array( '
', '[att~="b"s]' ), + 'attribute one of whitespace cases' => array( "
", '[att~="b"]' ), + + 'attribute with-hyphen (no hyphen)' => array( '

', '[att|="special"]' ), + 'attribute with-hyphen (hyphen prefix)' => array( '

', '[att|="special"]' ), + 'attribute with-hyphen insensitive' => array( '

', '[att|="special"i]' ), + 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]' ), + + 'attribute prefixed' => array( '

', '[att^="p"]' ), + 'attribute prefixed insensitive' => array( '

', '[att^="p"i]' ), + 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]' ), + + 'attribute suffixed' => array( '

', '[att$="x"]' ), + 'attribute suffixed insensitive' => array( '

', '[att$="x"i]' ), + 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]' ), + + 'attribute contains' => array( '

', '[att*="x"]' ), + 'attribute contains insensitive' => array( '

', '[att*="x"i]' ), + 'attribute contains sensitive mod' => array( '

', '[att*="x"s]' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att="bar"]' ), ); } From 4d4c5fe2db713a4a85a8c4073e3e39f44731d140 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:48:39 +0100 Subject: [PATCH 083/336] Extract normalize input method --- .../class-wp-css-complex-selector-list.php | 16 +------ .../class-wp-css-compound-selector-list.php | 43 +++++++++++++------ 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index f3769a035f6e5..59b08532868a8 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -43,26 +43,12 @@ class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List impleme * @return static|null */ public static function from_selectors( string $input ) { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… - $input = trim( $input, " \t\r\n\r" ); + $input = self::normalize_selector_input( $input ); if ( '' === $input ) { return null; } - /* - * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. - * > - * > To filter code points from a stream of (unfiltered) code points input: - * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. - * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). - * - * https://www.w3.org/TR/css-syntax-3/#input-preprocessing - */ - $input = str_replace( array( "\r\n" ), "\n", $input ); - $input = str_replace( array( "\r", "\f" ), "\n", $input ); - $input = str_replace( "\0", "\u{FFFD}", $input ); - $offset = 0; $selector = self::parse_complex_selector( $input, $offset ); diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 2aae51d671f6b..a41b0ac9cd530 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -120,25 +120,12 @@ protected function __construct( array $selectors ) { * @return static|null */ public static function from_selectors( string $input ) { - $input = trim( $input, " \t\r\n\r" ); + $input = self::normalize_selector_input( $input ); if ( '' === $input ) { return null; } - /* - * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. - * > - * > To filter code points from a stream of (unfiltered) code points input: - * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. - * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). - * - * https://www.w3.org/TR/css-syntax-3/#input-preprocessing - */ - $input = str_replace( array( "\r\n" ), "\n", $input ); - $input = str_replace( array( "\r", "\f" ), "\n", $input ); - $input = str_replace( "\0", "\u{FFFD}", $input ); - $offset = 0; $selector = self::parse_compound_selector( $input, $offset ); @@ -842,4 +829,32 @@ final protected static function check_if_three_code_points_would_start_an_ident_ // > Return false. return self::is_ident_start_codepoint( $input, $offset ); } + + /** + * @todo doc… + */ + final protected static function normalize_selector_input( string $input ): string { + /* + * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + * + * This list includes \f. + * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. + */ + $input = trim( $input, " \t\r\n\r\f" ); + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + return $input; + } } From dbc37fc2d819057c9678364021d1d14ee8f91292 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:52:54 +0100 Subject: [PATCH 084/336] tests --- tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index d94b61d49c14e..2a20e317338bd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -366,6 +366,7 @@ public static function data_attribute_selectors(): array { 'Invalid: [att s]' => array( '[att s]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From d241f31643a14f70ed3469121d6f45ce0db143d0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:57:08 +0100 Subject: [PATCH 085/336] Add nonfinal subclass selector test --- .../html-api/class-wp-css-complex-selector-list.php | 8 ++++++-- .../tests/html-api/wpCssComplexSelectorList.php | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 59b08532868a8..0413b8dea426a 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -134,9 +134,13 @@ final protected static function parse_complex_selector( string $input, int &$off return null; } - // `div > .className` is valid, but `.className > div` is not. + /* + * Subclass selectors in non-final position is not supported: + * - `div > .className` is valid + * - `.className > div` is not + */ if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); + return null; } $has_preceding_subclass_selector = null !== $selector->subclass_selectors; diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 5b485a5029db5..5cceddbdddd30 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -59,6 +59,16 @@ public function test_parse_invalid_complex_selector() { $this->assertNull( $result ); } + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector_nonfinal_subclass() { + $input = 'el.foo#bar[baz=quux] > final, rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + /** * @ticket TBD */ From 663070b34b7b9b04413a6d8b7cf0f20645d7eadb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 12:38:54 +0100 Subject: [PATCH 086/336] Fix logic bug in child selector exploration --- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 520f3bf3d8fde..ed4d2e7a6e662 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -46,7 +46,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); } - return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + return false; case self::COMBINATOR_DESCENDANT: // Find _all_ the breadcrumbs that match and recurse from each of them. From 5478af99a8ecbbff54503f3230f247bc06f56fdf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 12:54:58 +0100 Subject: [PATCH 087/336] Improve selector integration tests --- .../tests/html-api/wpHtmlProcessor-select.php | 62 +++++++------- .../html-api/wpHtmlTagProcessor-select.php | 83 +++++++++---------- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 733a7135f1b17..8515be63d83f8 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -26,54 +26,60 @@ public function test_select_miss() { * * @dataProvider data_selectors */ - public function test_select( string $html, string $selector ) { + public function test_select_all( string $html, string $selector, int $match_count ) { $processor = WP_HTML_Processor::create_full_parser( $html ); - $this->assertTrue( $processor->select( $selector ) ); - $this->assertTrue( $processor->get_attribute( 'match' ) ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $breadcrumb_string = implode( ', ', $processor->get_breadcrumbs() ); + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()} @ {$breadcrumb_string}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); } /** * Data provider. * + * Most selectors are covered by the tag processor selector tests. + * This suite should focus on complex selectors. + * * @return array */ public static function data_selectors(): array { return array( - 'simple type' => array( '
', 'div' ), - 'any type' => array( '', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'simple attribute' => array( '
', '[att]' ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), - 'complex any descendant' => array( '
', 'section *' ), - 'complex any child' => array( '
', 'section > *' ), - - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att~="bar"]' ), + 'any descendant' => array( '

', 'section *', 4 ), + 'any child 1' => array( '

', 'section > *', 2 ), + 'any child 2' => array( '

', 'div > *', 1 ), ); } /** * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Processor::select_all + * + * @dataProvider data_invalid_selectors */ - public function test_select_all() { - $processor = WP_HTML_Processor::create_full_parser( '

' ); - $count = 0; - foreach ( $processor->select_all( 'div, .x, svg>rect, #y' ) as $_ ) { - ++$count; - $this->assertTrue( $processor->get_attribute( 'match' ) ); - } - $this->assertSame( 4, $count ); + public function test_invalid_selector( string $selector ) { + $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); } /** - * @ticket TBD + * Data provider. * - * @expectedIncorrectUsage WP_HTML_Processor::select_all + * @return array */ - public function test_invalid_selector() { - $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); - $this->assertFalse( $processor->select( '[invalid!selector]' ) ); + public static function data_invalid_selectors(): array { + return array( + 'invalid selector' => array( '[invalid!selector]' ), + + // The class selectors below are not allowed in non-final position. + 'unsupported child selector' => array( '.parent > .child' ), + 'unsupported descendant selector' => array( '.ancestor .descendant' ), + ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 66f32f905c04f..6bc6ba1e6edbc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -26,10 +26,17 @@ public function test_select_miss() { * * @dataProvider data_selectors */ - public function test_select( string $html, string $selector ) { + public function test_select( string $html, string $selector, int $match_count ) { $processor = new WP_HTML_Tag_Processor( $html ); - $this->assertTrue( $processor->select( $selector ) ); - $this->assertTrue( $processor->get_attribute( 'match' ) ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); } /** @@ -39,58 +46,44 @@ public function test_select( string $html, string $selector ) { */ public static function data_selectors(): array { return array( - 'simple type' => array( '

', 'div' ), - 'any type' => array( '
', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'boolean attribute' => array( '
', '[att]' ), - 'boolean attribute with string match' => array( '
', '[att=""]' ), + 'simple type' => array( '
', 'div', 2 ), + 'any type' => array( '
', '*', 2 ), + 'simple class' => array( '
', '.x', 2 ), + 'simple id' => array( '
', '#x', 2 ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), - 'attribute case insensitive' => array( '
', '[att="VAL"i]' ), - 'attribute case sensitive mod' => array( '
', '[att="val"s]' ), + 'attribute presence' => array( '
', '[att]', 2 ), + 'attribute empty string match' => array( '
', '[att=""]', 2 ), + 'attribute value' => array( '

', '[att=val]', 2 ), + 'attribute quoted value' => array( '

', '[att="::"]', 2 ), + 'attribute case insensitive' => array( '

', '[att="VAL"i]', 2 ), + 'attribute case sensitive mod' => array( '

', '[att="val"s]', 2 ), - 'attribute one of' => array( '

', '[att~="b"]' ), - 'attribute one of insensitive' => array( '
', '[att~="b"i]' ), - 'attribute one of mod sensitive' => array( '
', '[att~="b"s]' ), - 'attribute one of whitespace cases' => array( "
", '[att~="b"]' ), + 'attribute one of' => array( '

', '[att~="b"]', 3 ), + 'attribute one of insensitive' => array( '

', '[att~="b"i]', 1 ), + 'attribute one of mod sensitive' => array( '
', '[att~="b"s]', 1 ), + 'attribute one of whitespace cases' => array( "
", '[att~="b"]', 1 ), - 'attribute with-hyphen (no hyphen)' => array( '

', '[att|="special"]' ), - 'attribute with-hyphen (hyphen prefix)' => array( '

', '[att|="special"]' ), - 'attribute with-hyphen insensitive' => array( '

', '[att|="special"i]' ), - 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]' ), + 'attribute with-hyphen' => array( '

', '[att|="special"]', 2 ), + 'attribute with-hyphen insensitive' => array( '

', '[att|="special" i]', 2 ), + 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]', 1 ), - 'attribute prefixed' => array( '

', '[att^="p"]' ), - 'attribute prefixed insensitive' => array( '

', '[att^="p"i]' ), - 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]' ), + 'attribute prefixed' => array( '

', '[att^="p"]', 2 ), + 'attribute prefixed insensitive' => array( '

', '[att^="p"i]', 1 ), + 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]', 1 ), - 'attribute suffixed' => array( '

', '[att$="x"]' ), - 'attribute suffixed insensitive' => array( '

', '[att$="x"i]' ), - 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]' ), + 'attribute suffixed' => array( '

', '[att$="x"]', 2 ), + 'attribute suffixed insensitive' => array( '

', '[att$="x"i]', 1 ), + 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]', 1 ), - 'attribute contains' => array( '

', '[att*="x"]' ), - 'attribute contains insensitive' => array( '

', '[att*="x"i]' ), - 'attribute contains sensitive mod' => array( '

', '[att*="x"s]' ), + 'attribute contains' => array( '

', '[att*="x"]', 2 ), + 'attribute contains insensitive' => array( '

', '[att*="x"i]', 1 ), + 'attribute contains sensitive mod' => array( '

', '[att*="x"s]', 1 ), - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att="bar"]' ), + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), + 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), ); } - /** - * @ticket TBD - */ - public function test_select_all() { - $processor = new WP_HTML_Tag_Processor( '

' ); - $count = 0; - foreach ( $processor->select_all( 'div, .x, rect, #y' ) as $_ ) { - ++$count; - $this->assertTrue( $processor->get_attribute( 'match' ) ); - } - $this->assertSame( 4, $count ); - } - /** * @ticket TBD * From 4f6bf948404cae07425b676048109be3a52d8853 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 13:13:03 +0100 Subject: [PATCH 088/336] Try abstract class instead of interface --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- .../html-api/interface-wp-css-html-processor-matcher.php | 4 ++-- .../html-api/interface-wp-css-html-tag-processor-matcher.php | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 17787dd70815b..4cf554c10eca9 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,6 +1,6 @@ has_class( $this->ident ); } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 0413b8dea426a..669139097fa75 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -32,7 +32,7 @@ * * @access private */ -class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { /** * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index ed4d2e7a6e662..4f83476898ec0 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -5,7 +5,7 @@ * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { +final class WP_CSS_Complex_Selector extends WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a41b0ac9cd530..0095b22977b0a 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -76,7 +76,7 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { +class WP_CSS_Compound_Selector_List extends WP_CSS_HTML_Tag_Processor_Matcher { /** * @param WP_HTML_Tag_Processor $processor * @return bool diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index e64695abe9ab3..3340515569bdd 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -5,7 +5,7 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Compound_Selector extends WP_CSS_HTML_Tag_Processor_Matcher { public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 83339ff839317..15cb2745ede9e 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ get_tag(); if ( null === $tag_name ) { diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php index 2ae29413b35d2..aa280ddefa696 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -1,8 +1,8 @@ Date: Thu, 5 Dec 2024 13:13:06 +0100 Subject: [PATCH 089/336] Revert "Try abstract class instead of interface" This reverts commit 74881651faf991eabceb090707ce8b43c2a25316. --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- .../html-api/interface-wp-css-html-processor-matcher.php | 4 ++-- .../html-api/interface-wp-css-html-tag-processor-matcher.php | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 4cf554c10eca9..17787dd70815b 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,6 +1,6 @@ has_class( $this->ident ); } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 669139097fa75..0413b8dea426a 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -32,7 +32,7 @@ * * @access private */ -class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { /** * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 4f83476898ec0..ed4d2e7a6e662 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -5,7 +5,7 @@ * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector extends WP_CSS_HTML_Processor_Matcher { +final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 0095b22977b0a..a41b0ac9cd530 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -76,7 +76,7 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Compound_Selector_List extends WP_CSS_HTML_Tag_Processor_Matcher { +class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { /** * @param WP_HTML_Tag_Processor $processor * @return bool diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 3340515569bdd..e64695abe9ab3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -5,7 +5,7 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector extends WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 15cb2745ede9e..83339ff839317 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ get_tag(); if ( null === $tag_name ) { diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php index aa280ddefa696..2ae29413b35d2 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -1,8 +1,8 @@ Date: Thu, 5 Dec 2024 14:51:39 +0100 Subject: [PATCH 090/336] Clean up and document attribute selector --- .../class-wp-css-attribute-selector.php | 214 ++++++++++-------- 1 file changed, 122 insertions(+), 92 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 17787dd70815b..7036dd3775cc1 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,96 +1,23 @@ get_attribute( $this->name ); - if ( null === $att_value ) { - return false; - } - - if ( null === $this->value ) { - return true; - } - - if ( true === $att_value ) { - $att_value = ''; - } - - $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; - - switch ( $this->matcher ) { - case self::MATCH_EXACT: - return $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value; - - case self::MATCH_ONE_OF_EXACT: - foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { - if ( - $case_insensitive - ? 0 === strcasecmp( $val, $this->value ) - : $val === $this->value - ) { - return true; - } - } - return false; - - case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: - // Attempt the full match first - if ( - $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value - ) { - return true; - } - - // Partial match - if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { - return false; - } - - $starts_with = "{$this->value}-"; - return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); - - case self::MATCH_PREFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); - - case self::MATCH_SUFFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); - - case self::MATCH_CONTAINS: - return false !== ( - $case_insensitive - ? stripos( $att_value, $this->value ) - : strpos( $att_value, $this->value ) - ); - } - } - - /** - * @param string $input - * - * @return Generator - */ - private function whitespace_delimited_list( string $input ): Generator { - // Start by skipping whitespace. - $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); - - while ( $offset < strlen( $input ) ) { - // Find the byte length until the next boundary. - $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); - $value = substr( $input, $offset, $length ); - - // Move past trailing whitespace. - $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); - - yield $value; - } - } - /** * [att=val] * Represents an element with the att attribute whose value is exactly "val". @@ -145,11 +72,11 @@ private function whitespace_delimited_list( string $input ): Generator { */ const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; - /** * The attribute name. * * @var string + * @readonly */ public $name; @@ -157,6 +84,7 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute matcher. * * @var null|self::MATCH_* + * @readonly */ public $matcher; @@ -164,6 +92,7 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute value. * * @var string|null + * @readonly */ public $value; @@ -171,10 +100,13 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute modifier. * * @var null|self::MODIFIER_* + * @readonly */ public $modifier; /** + * Constructor. + * * @param string $name * @param null|self::MATCH_* $matcher * @param null|string $value @@ -186,4 +118,102 @@ public function __construct( string $name, ?string $matcher = null, ?string $val $this->value = $value; $this->modifier = $modifier; } + + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor + * @return bool True if the processor's current position matches the selector. + */ + public function matches( WP_HTML_Tag_Processor $processor ): bool { + $att_value = $processor->get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + if ( true === $att_value ) { + $att_value = ''; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) + ); + } + } + + /** + * Splits a string into a list of whitespace delimited values. + * + * This is useful for the {@see WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT} matcher. + * + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + // Start by skipping whitespace. + $offset = strspn( $input, " \t\r\n\f" ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, " \t\r\n\f", $offset ); + $value = substr( $input, $offset, $length ); + + // Move past trailing whitespace. + $offset += $length + strspn( $input, " \t\r\n\f", $offset + $length ); + + yield $value; + } + } } From 32ee2a71197572ea713b6a9a3ee1a9e6b53c0d09 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 19:43:08 +0100 Subject: [PATCH 091/336] Update ticket number in tests --- .../html-api/wpCssComplexSelectorList.php | 16 ++++++------ .../html-api/wpCssCompoundSelectorList.php | 26 +++++++++---------- .../tests/html-api/wpHtmlProcessor-select.php | 6 ++--- .../html-api/wpHtmlTagProcessor-select.php | 6 ++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 5cceddbdddd30..0b17e57847662 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -27,7 +27,7 @@ public static function test_parse_complex_selector( string $input, int &$offset } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_complex_selector() { $input = 'el1 > .child#bar[baz=quux] , rest'; @@ -50,7 +50,7 @@ public function test_parse_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_complex_selector() { $input = 'el.foo#bar[baz=quux] > , rest'; @@ -60,7 +60,7 @@ public function test_parse_invalid_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_complex_selector_nonfinal_subclass() { $input = 'el.foo#bar[baz=quux] > final, rest'; @@ -70,7 +70,7 @@ public function test_parse_invalid_complex_selector_nonfinal_subclass() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_complex_selector() { $input = ''; @@ -80,7 +80,7 @@ public function test_parse_empty_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_complex_selector_list() { $input = 'el1 el2 el.foo#bar[baz=quux], second > selector'; @@ -89,7 +89,7 @@ public function test_parse_complex_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list() { $input = 'el,,'; @@ -98,7 +98,7 @@ public function test_parse_invalid_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list2() { $input = 'el!'; @@ -107,7 +107,7 @@ public function test_parse_invalid_selector_list2() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 2a20e317338bd..b5a2d9956679d 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -114,7 +114,7 @@ public static function data_idents(): array { } /** - * @ticket TBD + * @ticket 62653 */ public function test_is_ident_and_is_ident_start() { $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); @@ -124,7 +124,7 @@ public function test_is_ident_and_is_ident_start() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_idents */ @@ -141,7 +141,7 @@ public function test_parse_ident( string $input, ?string $expected = null, ?stri } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_strings */ @@ -192,7 +192,7 @@ public static function data_strings(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_id_selectors */ @@ -226,7 +226,7 @@ public static function data_id_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_class_selectors */ @@ -260,7 +260,7 @@ public static function data_class_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_type_selectors */ @@ -296,7 +296,7 @@ public static function data_type_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_attribute_selectors */ @@ -371,7 +371,7 @@ public static function data_attribute_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_selector() { $input = 'el.foo#bar[baz=quux] > .child'; @@ -389,7 +389,7 @@ public function test_parse_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector() { $input = ''; @@ -400,7 +400,7 @@ public function test_parse_empty_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_selector_list() { $input = 'el1, el2, el.foo#bar[baz=quux]'; @@ -409,7 +409,7 @@ public function test_parse_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list() { $input = 'el,,'; @@ -418,7 +418,7 @@ public function test_parse_invalid_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list2() { $input = 'el!'; @@ -427,7 +427,7 @@ public function test_parse_invalid_selector_list2() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 8515be63d83f8..40e1d96978afe 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -14,7 +14,7 @@ */ class Tests_HtmlApi_WpHtmlProcessor_Select extends WP_UnitTestCase { /** - * @ticket TBD + * @ticket 62653 */ public function test_select_miss() { $processor = WP_HTML_Processor::create_full_parser( '' ); @@ -22,7 +22,7 @@ public function test_select_miss() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_selectors */ @@ -57,7 +57,7 @@ public static function data_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @expectedIncorrectUsage WP_HTML_Processor::select_all * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 6bc6ba1e6edbc..586e38b4bafb2 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -14,7 +14,7 @@ */ class Tests_HtmlApi_WpHtmlTagProcessor_Select extends WP_UnitTestCase { /** - * @ticket TBD + * @ticket 62653 */ public function test_select_miss() { $processor = new WP_HTML_Tag_Processor( '' ); @@ -22,7 +22,7 @@ public function test_select_miss() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_selectors */ @@ -85,7 +85,7 @@ public static function data_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all * From 5922494030b000bf4d229975a5fd1968c14b20fc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 21:28:24 +0100 Subject: [PATCH 092/336] Improve some types --- .../html-api/class-wp-css-complex-selector.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index ed4d2e7a6e662..a4cfd46622560 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -16,7 +16,7 @@ public function matches( WP_HTML_Processor $processor ): bool { return true; } - /** @var array $breadcrumbs */ + /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); $selectors = array_slice( $this->selectors, 1 ); return $this->explore_matches( $selectors, $breadcrumbs ); @@ -26,7 +26,7 @@ public function matches( WP_HTML_Processor $processor ): bool { * This only looks at breadcrumbs and can therefore only support type selectors. * * @param array $selectors - * @param array $breadcrumbs + * @param string[] $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -36,9 +36,9 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } - /** @var self::COMBINATOR_* $combinator */ + /** @var self::COMBINATOR_* */ $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector $selector */ + /** @var WP_CSS_Compound_Selector */ $selector = $selectors[1]; switch ( $combinator ) { From e492aa60e2db167ec87a048f64fab13378ec4694 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:16:57 +0100 Subject: [PATCH 093/336] Fix and improve string token parsing --- .../class-wp-css-compound-selector-list.php | 19 +++++++++++++------ .../html-api/wpCssCompoundSelectorList.php | 9 ++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a41b0ac9cd530..8cca2e27c9ec3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -548,7 +548,7 @@ final protected static function parse_ident( string $input, int &$offset ): ?str * @return string|null */ final protected static function parse_string( string $input, int &$offset ): ?string { - if ( $offset + 1 >= strlen( $input ) ) { + if ( $offset >= strlen( $input ) ) { return null; } @@ -559,8 +559,19 @@ final protected static function parse_string( string $input, int &$offset ): ?st $string_token = ''; - $updated_offset = $offset + 1; + $updated_offset = $offset + 1; + $anything_else_mask = "\\\n{$ending_code_point}"; while ( $updated_offset < strlen( $input ) ) { + $anything_else_length = strcspn( $input, $anything_else_mask, $updated_offset ); + if ( $anything_else_length > 0 ) { + $string_token .= substr( $input, $updated_offset, $anything_else_length ); + $updated_offset += $anything_else_length; + + if ( $updated_offset >= strlen( $input ) ) { + break; + } + } + switch ( $input[ $updated_offset ] ) { case '\\': ++$updated_offset; @@ -587,10 +598,6 @@ final protected static function parse_string( string $input, int &$offset ): ?st case $ending_code_point: ++$updated_offset; break 2; - - default: - $string_token .= $input[ $updated_offset ]; - ++$updated_offset; } } diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index b5a2d9956679d..715e0e26bc9cd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -181,13 +181,16 @@ public static function data_strings(): array { "'foo\\" => array( "'foo\\", 'foo', '' ), + '"' => array( '"', '', '' ), + '"\\"' => array( '"\\"', '"', '' ), + '"missing close' => array( '"missing close', 'missing close', '' ), + // Invalid 'Invalid: (empty string)' => array( '' ), - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: \\"' => array( '\\"' ), 'Invalid: .foo' => array( '.foo' ), 'Invalid: #foo' => array( '#foo' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), ); } From 81c67582deef44766e188482586538cdbe84272d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:17:11 +0100 Subject: [PATCH 094/336] Update attribute selector tests --- .../html-api/wpCssCompoundSelectorList.php | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 715e0e26bc9cd..6d1b142c17ea9 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -331,45 +331,53 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - '[href]' => array( '[href]', 'href', null, null, null, '' ), - '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), - '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), - '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), - '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), - '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), - '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), - - '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), - "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), - '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + + 'Unterminated: [att' => array( '[att', 'att', null, null, null, '' ), + 'Unterminated: [att="' => array( '[att="', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '', null, '' ), + 'Unterminated: [att="\\"' => array( '[att="\\"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '"', null, '' ), + 'Unterminated: [att="x"' => array( '[att="x"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', null, '' ), + 'Unterminated: [att="x\\"i]' => array( '[att="x\\"i]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x"i]', null, '' ), + 'Unterminated: [att="x" i' => array( '[att="x" i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + 'Unterminated: [att = x i' => array( '[att = x i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), - 'Invalid: [#foo]' => array( '[#foo]' ), - 'Invalid: [*|*]' => array( '[*|*]' ), - 'Invalid: [ns|*]' => array( '[ns|*]' ), - 'Invalid: [* |att]' => array( '[* |att]' ), - 'Invalid: [*| att]' => array( '[*| att]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att+=val]' => array( '[att+=val]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), - 'Invalid: [att i]' => array( '[att i]' ), - 'Invalid: [att s]' => array( '[att s]' ), - "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), - 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From 7bccf3eada582c8b66ec24781dc151a1afbfe9b6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:36:26 +0100 Subject: [PATCH 095/336] Revert "Update attribute selector tests" This reverts commit 7df9ed91a1360d80c1dcb87980af941010b926ba. --- .../html-api/wpCssCompoundSelectorList.php | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 6d1b142c17ea9..715e0e26bc9cd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -331,53 +331,45 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - '[href]' => array( '[href]', 'href', null, null, null, '' ), - '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), - '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), - '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), - '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), - '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), - '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), - - '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), - "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), - '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), - - 'Unterminated: [att' => array( '[att', 'att', null, null, null, '' ), - 'Unterminated: [att="' => array( '[att="', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '', null, '' ), - 'Unterminated: [att="\\"' => array( '[att="\\"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '"', null, '' ), - 'Unterminated: [att="x"' => array( '[att="x"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', null, '' ), - 'Unterminated: [att="x\\"i]' => array( '[att="x\\"i]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x"i]', null, '' ), - 'Unterminated: [att="x" i' => array( '[att="x" i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - 'Unterminated: [att = x i' => array( '[att = x i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), - 'Invalid: [#foo]' => array( '[#foo]' ), - 'Invalid: [*|*]' => array( '[*|*]' ), - 'Invalid: [ns|*]' => array( '[ns|*]' ), - 'Invalid: [* |att]' => array( '[* |att]' ), - 'Invalid: [*| att]' => array( '[*| att]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att+=val]' => array( '[att+=val]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), - 'Invalid: [att i]' => array( '[att i]' ), - 'Invalid: [att s]' => array( '[att s]' ), - "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), - 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From 3949cc53b4bebdc8324a07a8ce49bd6ede291e53 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:51:04 +0100 Subject: [PATCH 096/336] Improve some complex selector match tests --- .../tests/html-api/wpHtmlProcessor-select.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 40e1d96978afe..d94190ff91077 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -43,16 +43,18 @@ public function test_select_all( string $html, string $selector, int $match_coun /** * Data provider. * - * Most selectors are covered by the tag processor selector tests. - * This suite should focus on complex selectors. - * * @return array */ public static function data_selectors(): array { return array( - 'any descendant' => array( '

', 'section *', 4 ), - 'any child 1' => array( '

', 'section > *', 2 ), - 'any child 2' => array( '

', 'div > *', 1 ), + 'any' => array( '

', '*', 5 ), + 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), + 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), + 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), + 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), + 'any descendant' => array( '

', 'section *', 4 ), + 'any child 1' => array( '

', 'section > *', 2 ), + 'any child 2' => array( '

', 'div > *', 1 ), ); } From c696889197fab2308490cab7b47bf654eed63a61 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 15:45:55 +0100 Subject: [PATCH 097/336] Add and use matches_tag type selector method --- .../html-api/class-wp-css-complex-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-type-selector.php | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index a4cfd46622560..a532e87ecc15d 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -43,7 +43,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { switch ( $combinator ) { case self::COMBINATOR_CHILD: - if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + if ( $selector->type_selector->matches_tag( $breadcrumbs[0] ) ) { return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); } return false; @@ -51,7 +51,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { case self::COMBINATOR_DESCENDANT: // Find _all_ the breadcrumbs that match and recurse from each of them. for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { - if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + if ( $selector->type_selector->matches_tag( $breadcrumbs[ $i ] ) ) { $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { return true; diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index c65adce14047d..2a6bb952f5448 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -6,6 +6,14 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( null === $tag_name ) { return false; } + return $this->matches_tag( $tag_name ); + } + + /** + * @param string $tag_name + * @return bool + */ + public function matches_tag( string $tag_name ): bool { if ( '*' === $this->ident ) { return true; } From c19355151ee667b055d3414c9272907e37069b82 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 16:00:36 +0100 Subject: [PATCH 098/336] Improve complex selector structure Separate the self selector from relative selectors --- .../class-wp-css-complex-selector-list.php | 50 ++++---- .../class-wp-css-complex-selector.php | 110 ++++++++++++------ .../class-wp-css-compound-selector.php | 2 +- .../html-api/wpCssComplexSelectorList.php | 25 ++-- .../tests/html-api/wpHtmlProcessor-select.php | 17 +-- 5 files changed, 123 insertions(+), 81 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 0413b8dea426a..4a9fc03f582f8 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -95,16 +95,18 @@ final protected static function parse_complex_selector( string $input, int &$off } $updated_offset = $offset; - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { + $self_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $self_selector ) { return null; } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + /** @var array{WP_CSS_Compound_Selector, string}[] */ + $selectors = array(); $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { + $combinator = null; + $next_selector = null; + if ( WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || @@ -114,42 +116,40 @@ final protected static function parse_complex_selector( string $input, int &$off ++$updated_offset; self::parse_whitespace( $input, $updated_offset ); - // Failure to find a selector here is a parse error - $selector = self::parse_compound_selector( $input, $updated_offset ); + // A combinator has been found, failure to find a selector here is a parse error. + $next_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $next_selector ) { + return null; + } } elseif ( $found_whitespace ) { /* * Whitespace is ambiguous, it could be a descendant combinator or * insignificant whitespace. */ - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { - break; + $next_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null !== $next_selector ) { + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; } - $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; - } else { - break; } - if ( null === $selector ) { - return null; + if ( null === $next_selector ) { + break; } - /* - * Subclass selectors in non-final position is not supported: - * - `div > .className` is valid - * - `.className > div` is not - */ - if ( $has_preceding_subclass_selector ) { + // $self_selector will pass to a relative selector where only the type selector is allowed. + if ( null !== $self_selector->subclass_selectors || null === $self_selector->type_selector ) { return null; } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - $selectors[] = $combinator; - $selectors[] = $selector; + /** @var array{WP_CSS_Compound_Selector, string} */ + $selector_pair = array( $self_selector->type_selector, $combinator ); + $selectors[] = $selector_pair; + $self_selector = $next_selector; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; - return new WP_CSS_Complex_Selector( $selectors ); + + return new WP_CSS_Complex_Selector( $self_selector, array_reverse( $selectors ) ); } } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index a532e87ecc15d..9db2912d3ac16 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -6,26 +6,87 @@ * > = [ ? ] * */ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * This is the selector in the final position of the complex selector. This corresponds to the + * selected element. + * + * @example + * + * $self_selector + * ┏━━━━┻━━━━┓ + * .heading h1 > el.selected + * + * @readonly + * @var WP_CSS_Compound_Selector + */ + public $self_selector; + + /** + * This is the selector in the final position of the complex selector. This corresponds to the + * selected element. + * + * @example + * + * $relative_selectors + * ┏━━━━━━┻━━━━┓ + * .heading h1 > el.selected + * + * The example would have the following relative selectors (note that the order is reversed): + * + * @example + * + * array ( + * array( + * WP_CSS_Type_Selector( 'ident' => 'h1' ), + * '>', // WP_CSS_Complex_Selector::COMBINATOR_CHILD + * ), + * array( + * new WP_CSS_Type_Selector( 'header' ), + * ' ', // WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT + * ), + * ) + * + * @readonly + * @var array{WP_CSS_Type_Selector, string}[] + */ + public $relative_selectors; + + /** + * @param WP_CSS_Compound_Selector $self_selector + * @param array{WP_CSS_Type_Selector, string}[] $selectors + */ + public function __construct( + WP_CSS_Compound_Selector $self_selector, + ?array $relative_selectors + ) { + $this->self_selector = $self_selector; + $this->relative_selectors = $relative_selectors; + } + public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. - if ( ! $this->selectors[0]->matches( $processor ) ) { + if ( ! $this->self_selector->matches( $processor ) ) { return false; } - if ( count( $this->selectors ) === 1 ) { + if ( null === $this->relative_selectors || array() === $this->relative_selectors ) { return true; } /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); - $selectors = array_slice( $this->selectors, 1 ); - return $this->explore_matches( $selectors, $breadcrumbs ); + return $this->explore_matches( $this->relative_selectors, $breadcrumbs ); } /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array{WP_CSS_Type_Selector, string}[] $selectors * @param string[] $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { @@ -36,24 +97,22 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } - /** @var self::COMBINATOR_* */ - $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector */ - $selector = $selectors[1]; + $selector = $selectors[0][0]; + $combinator = $selectors[0][1]; switch ( $combinator ) { case self::COMBINATOR_CHILD: - if ( $selector->type_selector->matches_tag( $breadcrumbs[0] ) ) { - return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + if ( $selector->matches_tag( $breadcrumbs[0] ) ) { + return $this->explore_matches( array_slice( $selectors, 1 ), array_slice( $breadcrumbs, 1 ) ); } return false; case self::COMBINATOR_DESCENDANT: // Find _all_ the breadcrumbs that match and recurse from each of them. for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { - if ( $selector->type_selector->matches_tag( $breadcrumbs[ $i ] ) ) { - $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); - if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + if ( $selector->matches_tag( $breadcrumbs[ $i ] ) ) { + $next_breadcrumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 1 ), $next_breadcrumbs ) ) { return true; } } @@ -61,28 +120,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; default: - throw new Exception( "Combinator '{$combinator}' is not supported yet." ); + throw new Exception( "Unsupported combinator '{$combinator}' found." ); } } - - const COMBINATOR_CHILD = '>'; - const COMBINATOR_DESCENDANT = ' '; - const COMBINATOR_NEXT_SIBLING = '+'; - const COMBINATOR_SUBSEQUENT_SIBLING = '~'; - - /** - * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. - * In reverse order to match the current element and then work up the tree. - * Any non-final selector is a type selector. - * - * @var array - */ - public $selectors = array(); - - /** - * @param array $selectors - */ - public function __construct( array $selectors ) { - $this->selectors = array_reverse( $selectors ); - } } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index e64695abe9ab3..2ef2051880936 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -25,7 +25,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { /** @var WP_CSS_Type_Selector|null */ public $type_selector; - /** @var array|null */ + /** @var (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null */ public $subclass_selectors; /** diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 0b17e57847662..795e230033cdb 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -20,7 +20,7 @@ public function __construct() { parent::__construct( array() ); } - public static function test_parse_complex_selector( string $input, int &$offset ) { + public static function test_parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { return self::parse_complex_selector( $input, $offset ); } }; @@ -30,21 +30,24 @@ public static function test_parse_complex_selector( string $input, int &$offset * @ticket 62653 */ public function test_parse_complex_selector() { - $input = 'el1 > .child#bar[baz=quux] , rest'; + $input = 'el1 el2 > .child#bar[baz=quux] , rest'; $offset = 0; - $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertSame( 3, count( $sel->selectors ) ); + /** @var WP_CSS_Complex_Selector|null */ + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); - $this->assertNull( $sel->selectors[2]->subclass_selectors ); + $this->assertSame( 2, count( $sel->relative_selectors ) ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + // Relative selectors should be reverse ordered. + $this->assertSame( 'el2', $sel->relative_selectors[0][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->relative_selectors[0][1] ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertNull( $sel->selectors[0]->type_selector ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); + $this->assertSame( 'el1', $sel->relative_selectors[1][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->relative_selectors[1][1] ); + + $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); + $this->assertNull( $sel->self_selector->type_selector ); + $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->ident ); $this->assertSame( ', rest', substr( $input, $offset ) ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index d94190ff91077..21828faf42e80 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -47,14 +47,15 @@ public function test_select_all( string $html, string $selector, int $match_coun */ public static function data_selectors(): array { return array( - 'any' => array( '

', '*', 5 ), - 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), - 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), - 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), - 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), - 'any descendant' => array( '

', 'section *', 4 ), - 'any child 1' => array( '

', 'section > *', 2 ), - 'any child 2' => array( '

', 'div > *', 1 ), + 'any' => array( '

', '*', 5 ), + 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), + 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), + 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), + 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), + 'any descendant' => array( '

', 'section *', 4 ), + 'any child matches all children' => array( '

', 'section > *', 2 ), + + 'multiple complex selectors' => array( '

', 'section > div p > i', 1 ), ); } From 9dd811432a685b4efa15692a9d5d5dae43b475c0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 16:10:05 +0100 Subject: [PATCH 099/336] Rework structure of complex_selector class --- .../class-wp-css-complex-selector.php | 32 ++++++++++++------- .../html-api/wpCssComplexSelectorList.php | 10 +++--- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 9db2912d3ac16..1f03f133c8806 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -12,7 +12,7 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { const COMBINATOR_SUBSEQUENT_SIBLING = '~'; /** - * This is the selector in the final position of the complex selector. This corresponds to the + * The "self selector" is the last element in a complex selector, it corresponds to the * selected element. * * @example @@ -27,12 +27,20 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { public $self_selector; /** - * This is the selector in the final position of the complex selector. This corresponds to the - * selected element. + * The "context selectors" are zero or more elements that provide additional constraints for + * the "self selector." + * + * In this example selector, and element like `` is selected iff: + * - it is a child of an `H1` element + * - *and* that `H1` element is a descendant of a `HEADING` element. + * + * The `H1` and `HEADING` parts of this selector are the "context selectors." Note that this + * terminology is used for purposes of this class but does not correspond to language in the + * CSS or selector specifications. * * @example * - * $relative_selectors + * $context_selectors * ┏━━━━━━┻━━━━┓ * .heading h1 > el.selected * @@ -52,20 +60,20 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { * ) * * @readonly - * @var array{WP_CSS_Type_Selector, string}[] + * @var array{WP_CSS_Type_Selector, string}[]|null */ - public $relative_selectors; + public $context_selectors; /** * @param WP_CSS_Compound_Selector $self_selector - * @param array{WP_CSS_Type_Selector, string}[] $selectors + * @param array{WP_CSS_Type_Selector, string}[]|null $selectors */ public function __construct( WP_CSS_Compound_Selector $self_selector, - ?array $relative_selectors + ?array $context_selectors ) { - $this->self_selector = $self_selector; - $this->relative_selectors = $relative_selectors; + $this->self_selector = $self_selector; + $this->context_selectors = $context_selectors; } public function matches( WP_HTML_Processor $processor ): bool { @@ -74,13 +82,13 @@ public function matches( WP_HTML_Processor $processor ): bool { return false; } - if ( null === $this->relative_selectors || array() === $this->relative_selectors ) { + if ( null === $this->context_selectors || array() === $this->context_selectors ) { return true; } /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); - return $this->explore_matches( $this->relative_selectors, $breadcrumbs ); + return $this->explore_matches( $this->context_selectors, $breadcrumbs ); } /** diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 795e230033cdb..dc89869ea2e66 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -36,14 +36,14 @@ public function test_parse_complex_selector() { /** @var WP_CSS_Complex_Selector|null */ $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertSame( 2, count( $sel->relative_selectors ) ); + $this->assertSame( 2, count( $sel->context_selectors ) ); // Relative selectors should be reverse ordered. - $this->assertSame( 'el2', $sel->relative_selectors[0][0]->ident ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->relative_selectors[0][1] ); + $this->assertSame( 'el2', $sel->context_selectors[0][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->context_selectors[0][1] ); - $this->assertSame( 'el1', $sel->relative_selectors[1][0]->ident ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->relative_selectors[1][1] ); + $this->assertSame( 'el1', $sel->context_selectors[1][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->context_selectors[1][1] ); $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); $this->assertNull( $sel->self_selector->type_selector ); From b134308e4017f53d40d55df4aa0b03842d51974f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 16:54:08 +0100 Subject: [PATCH 100/336] Improve documentation --- .../html-api/class-wp-css-complex-selector.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 1f03f133c8806..2d4d0212b24f2 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -30,7 +30,11 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { * The "context selectors" are zero or more elements that provide additional constraints for * the "self selector." * - * In this example selector, and element like `` is selected iff: + * These selectors are represented as 2-tuples where the element at index 0 is the selector and + * the element at index 1 is the combinator string constant from this class, + * e.g. `WP_CSS_Complex_Selector::COMBINATOR_CHILD`. + * + * In the example selector below, an element like `` is selected iff: * - it is a child of an `H1` element * - *and* that `H1` element is a descendant of a `HEADING` element. * @@ -44,7 +48,7 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { * ┏━━━━━━┻━━━━┓ * .heading h1 > el.selected * - * The example would have the following relative selectors (note that the order is reversed): + * The example would have the following relative selectors: * * @example * @@ -59,6 +63,10 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { * ), * ) * + * Note that the order of context selectors is reversed. This is to match the self selector + * first and then match the context selectors beginning with the selector closest to the self + * selector. + * * @readonly * @var array{WP_CSS_Type_Selector, string}[]|null */ From 94c06ef32fd69eecd7ccddd40714edef1a79f493 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:11:55 +0100 Subject: [PATCH 101/336] Document complex selector class --- .../class-wp-css-complex-selector.php | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 2d4d0212b24f2..bd51884901d93 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -1,14 +1,47 @@ in the grammar. + * CSS complex selector. + * + * This class implements a CSS complex selector and is used to test for matching HTML tags + * in a {@see WP_HTML_Tag_Processor}. + * + * A complex selector is a selector with zero or more combinator-selector pairs. + * + * @since TBD * - * > = [ ? ] * + * @access private */ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { - const COMBINATOR_CHILD = '>'; - const COMBINATOR_DESCENDANT = ' '; - const COMBINATOR_NEXT_SIBLING = '+'; + /** + * Child combinator. + */ + const COMBINATOR_CHILD = '>'; + + /** + * Descendant combinator. + */ + const COMBINATOR_DESCENDANT = ' '; + + /** + * Next sibling combinator. + * + * This combinator is not currently supported. + */ + const COMBINATOR_NEXT_SIBLING = '+'; + + /** + * Subsequent sibling combinator. + * + * This combinator is not currently supported. + */ const COMBINATOR_SUBSEQUENT_SIBLING = '~'; /** @@ -84,6 +117,12 @@ public function __construct( $this->context_selectors = $context_selectors; } + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. + */ public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->self_selector->matches( $processor ) ) { @@ -100,10 +139,11 @@ public function matches( WP_HTML_Processor $processor ): bool { } /** - * This only looks at breadcrumbs and can therefore only support type selectors. + * Checks for matches recursively comparing context selectors with breadcrumbs. * * @param array{WP_CSS_Type_Selector, string}[] $selectors * @param string[] $breadcrumbs + * @return bool True if a match is found, otherwise false. */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { From f46fceda45dd38676191126761fb3c4c4439d0be Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:17:39 +0100 Subject: [PATCH 102/336] Document matches functions --- .../html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 6 ++++++ .../html-api/class-wp-css-compound-selector-list.php | 6 ++++-- src/wp-includes/html-api/class-wp-css-compound-selector.php | 6 ++++++ src/wp-includes/html-api/class-wp-css-id-selector.php | 6 ++++++ src/wp-includes/html-api/class-wp-css-type-selector.php | 6 ++++++ .../html-api/interface-wp-css-html-processor-matcher.php | 5 ++++- .../interface-wp-css-html-tag-processor-matcher.php | 5 ++++- 8 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 7036dd3775cc1..dae71c4295348 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -122,7 +122,7 @@ public function __construct( string $name, ?string $matcher = null, ?string $val /** * Determines if the processor's current position matches the selector. * - * @param WP_HTML_Tag_Processor $processor + * @param WP_HTML_Tag_Processor $processor The processor. * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Tag_Processor $processor ): bool { diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index c3e7ced008a6e..c9ab061578025 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -1,6 +1,12 @@ has_class( $this->ident ); } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 8cca2e27c9ec3..ce116a236e171 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -78,8 +78,10 @@ */ class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { /** - * @param WP_HTML_Tag_Processor $processor - * @return bool + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. */ public function matches( $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 2ef2051880936..0ae507803c42f 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -6,6 +6,12 @@ * > = [ ? * ]! */ final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. + */ public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 83339ff839317..7e64432430409 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -8,6 +8,12 @@ public function __construct( string $ident ) { $this->ident = $ident; } + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. + */ public function matches( WP_HTML_Tag_Processor $processor ): bool { $id = $processor->get_attribute( 'id' ); if ( ! is_string( $id ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index 2a6bb952f5448..6bba9f7e2450e 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -1,6 +1,12 @@ get_tag(); if ( null === $tag_name ) { diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php index 2ae29413b35d2..b77ef40931d83 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -2,7 +2,10 @@ interface WP_CSS_HTML_Processor_Matcher { /** - * @return bool + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Processor $processor ): bool; } diff --git a/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php index 73d108150bb95..302ee8972a162 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php @@ -2,7 +2,10 @@ interface WP_CSS_HTML_Tag_Processor_Matcher { /** - * @return bool + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor The processor. + * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Tag_Processor $processor ): bool; } From 1bacfd71810f4e39bcb5fd0eb83688c82878ea4a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:17:58 +0100 Subject: [PATCH 103/336] Simplify condition in compound::matches --- src/wp-includes/html-api/class-wp-css-compound-selector.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 0ae507803c42f..f281146110f30 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -13,10 +13,8 @@ final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matche * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Tag_Processor $processor ): bool { - if ( $this->type_selector ) { - if ( ! $this->type_selector->matches( $processor ) ) { - return false; - } + if ( $this->type_selector && ! $this->type_selector->matches( $processor ) ) { + return false; } if ( null !== $this->subclass_selectors ) { foreach ( $this->subclass_selectors as $subclass_selector ) { From a274ea0ffaed3785d12909c657b91594b76b13f4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:20:19 +0100 Subject: [PATCH 104/336] Change class require order --- src/wp-settings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-settings.php b/src/wp-settings.php index b1f25042aa7d6..b52fe8ab6181c 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -267,10 +267,10 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; require ABSPATH . WPINC . '/html-api/interface-wp-css-html-tag-processor-matcher.php'; require ABSPATH . WPINC . '/html-api/interface-wp-css-html-processor-matcher.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-attribute-selector.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-class-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-id-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-type-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-class-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-attribute-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-compound-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-complex-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-compound-selector-list.php'; From 12a0a99d4c4e7e51fcb18cae4b384dfe41f137a2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:21:12 +0100 Subject: [PATCH 105/336] Annotate matches processor argument type --- .../html-api/class-wp-css-compound-selector-list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index ce116a236e171..27900d40a238c 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -83,7 +83,7 @@ class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher * @param WP_HTML_Tag_Processor $processor The processor. * @return bool True if the processor's current position matches the selector. */ - public function matches( $processor ): bool { + public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; } From 0e2b34aba90e3dad4354c362efe363ec8bb63532 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:28:06 +0100 Subject: [PATCH 106/336] Document class selector and update class_name property --- .../html-api/class-wp-css-class-selector.php | 42 +++++++++++++++---- .../html-api/wpCssComplexSelectorList.php | 2 +- .../html-api/wpCssCompoundSelectorList.php | 4 +- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index c9ab061578025..cdd38d951e45c 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -1,6 +1,39 @@ class_name = $class_name; + } + /** * Determines if the processor's current position matches the selector. * @@ -8,13 +41,6 @@ final class WP_CSS_Class_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Tag_Processor $processor ): bool { - return (bool) $processor->has_class( $this->ident ); - } - - /** @var string */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; + return (bool) $processor->has_class( $this->class_name ); } } diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index dc89869ea2e66..1bf77f8c60317 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -47,7 +47,7 @@ public function test_parse_complex_selector() { $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); $this->assertNull( $sel->self_selector->type_selector ); - $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->ident ); + $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->class_name ); $this->assertSame( ', rest', substr( $input, $offset ) ); } diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 715e0e26bc9cd..fa45ed767d5ca 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -239,7 +239,7 @@ public function test_parse_class( string $input, ?string $expected = null, ?stri if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $expected, $result->ident ); + $this->assertSame( $expected, $result->class_name ); $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -383,7 +383,7 @@ public function test_parse_selector() { $this->assertSame( 'el', $sel->type_selector->ident ); $this->assertSame( 3, count( $sel->subclass_selectors ) ); - $this->assertSame( 'foo', $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( 'foo', $sel->subclass_selectors[0]->class_name, 'foo' ); $this->assertSame( 'bar', $sel->subclass_selectors[1]->ident, 'bar' ); $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); From dea10291c67b55512658af04bc483385965acc08 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:33:24 +0100 Subject: [PATCH 107/336] Document ID selector class, rename id property --- .../html-api/class-wp-css-id-selector.php | 38 ++++++++++++++++--- .../html-api/wpCssCompoundSelectorList.php | 4 +- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 7e64432430409..5bb6438df6eb3 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,11 +1,37 @@ ident = $ident; + /** + * Constructor. + * + * @param string $id The ID to match. + */ + public function __construct( string $id ) { + $this->id = $id; } /** @@ -23,7 +49,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { $case_insensitive = $processor->is_quirks_mode(); return $case_insensitive - ? 0 === strcasecmp( $id, $this->ident ) - : $processor->get_attribute( 'id' ) === $this->ident; + ? 0 === strcasecmp( $id, $this->id ) + : $processor->get_attribute( 'id' ) === $this->id; } } diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index fa45ed767d5ca..8334ebd5a3a75 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -205,7 +205,7 @@ public function test_parse_id( string $input, ?string $expected = null, ?string if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $expected, $result->ident ); + $this->assertSame( $expected, $result->id ); $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -384,7 +384,7 @@ public function test_parse_selector() { $this->assertSame( 'el', $sel->type_selector->ident ); $this->assertSame( 3, count( $sel->subclass_selectors ) ); $this->assertSame( 'foo', $sel->subclass_selectors[0]->class_name, 'foo' ); - $this->assertSame( 'bar', $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( 'bar', $sel->subclass_selectors[1]->id, 'bar' ); $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); From d268f4cfe03a3872865c94b5e03c2e815f106576 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 18:38:47 +0100 Subject: [PATCH 108/336] Document type selector class and rename type property --- .../html-api/class-wp-css-type-selector.php | 49 ++++++++++++++----- .../html-api/wpCssComplexSelectorList.php | 4 +- .../html-api/wpCssCompoundSelectorList.php | 4 +- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index 6bba9f7e2450e..66d6a1f2db48f 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -1,6 +1,38 @@ type = $type; + } + /** * Determines if the processor's current position matches the selector. * @@ -16,24 +48,15 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { } /** + * Checks whether the selector matches the provided tag name. + * * @param string $tag_name * @return bool */ public function matches_tag( string $tag_name ): bool { - if ( '*' === $this->ident ) { + if ( '*' === $this->type ) { return true; } - return 0 === strcasecmp( $tag_name, $this->ident ); - } - - /** - * @var string - * - * The type identifier string or '*'. - */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; + return 0 === strcasecmp( $tag_name, $this->type ); } } diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 1bf77f8c60317..076d5b6f65ee6 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -39,10 +39,10 @@ public function test_parse_complex_selector() { $this->assertSame( 2, count( $sel->context_selectors ) ); // Relative selectors should be reverse ordered. - $this->assertSame( 'el2', $sel->context_selectors[0][0]->ident ); + $this->assertSame( 'el2', $sel->context_selectors[0][0]->type ); $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->context_selectors[0][1] ); - $this->assertSame( 'el1', $sel->context_selectors[1][0]->ident ); + $this->assertSame( 'el1', $sel->context_selectors[1][0]->type ); $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->context_selectors[1][1] ); $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 8334ebd5a3a75..1dfdc79714e2c 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -273,7 +273,7 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $expected, $result->ident ); + $this->assertSame( $expected, $result->type ); $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -381,7 +381,7 @@ public function test_parse_selector() { $offset = 0; $sel = $this->test_class::test_parse_compound_selector( $input, $offset ); - $this->assertSame( 'el', $sel->type_selector->ident ); + $this->assertSame( 'el', $sel->type_selector->type ); $this->assertSame( 3, count( $sel->subclass_selectors ) ); $this->assertSame( 'foo', $sel->subclass_selectors[0]->class_name, 'foo' ); $this->assertSame( 'bar', $sel->subclass_selectors[1]->id, 'bar' ); From d89fbd989d86fb16f2d34eb896031d32db817055 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:03:35 +0100 Subject: [PATCH 109/336] Document compound selector --- .../class-wp-css-compound-selector.php | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index f281146110f30..19aad862db7e2 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -1,11 +1,55 @@ in the grammar. + * CSS compound selector. + * + * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. + * + * A compound selector is a combination of: + * - An optional type selector. + * - Zero or more subclass selectors (ID, class, or attribute selectors). + * - At least one of the above. * - * > = [ ? * ]! + * @since TBD + * + * @access private */ final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { + /** + * The type selector. + * + * @var WP_CSS_Type_Selector|null + */ + public $type_selector; + + /** + * The subclass selectors. + * + * Subclass selectors are ID, class, or attribute selectors. + * + * @var (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null + */ + public $subclass_selectors; + + /** + * Constructor. + * + * @param WP_CSS_Type_Selector|null $type_selector The type selector or null. + * @param (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null $subclass_selectors + * The array of subclass selectors or null. + */ + public function __construct( ?WP_CSS_Type_Selector $type_selector, ?array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } + /** * Determines if the processor's current position matches the selector. * @@ -25,19 +69,4 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { } return true; } - - /** @var WP_CSS_Type_Selector|null */ - public $type_selector; - - /** @var (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null */ - public $subclass_selectors; - - /** - * @param WP_CSS_Type_Selector|null $type_selector - * @param array $subclass_selectors - */ - public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { - $this->type_selector = $type_selector; - $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; - } } From 8ced3aa2da7f1c77a12b344516c9dae8eaad9be5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:03:59 +0100 Subject: [PATCH 110/336] Improve attribute selector docs and types --- .../class-wp-css-attribute-selector.php | 111 +++++++++++------- .../class-wp-css-compound-selector-list.php | 2 +- .../class-wp-css-compound-selector.php | 2 +- .../html-api/wpCssCompoundSelectorList.php | 2 +- 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index dae71c4295348..b64efea0bb45c 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -19,98 +19,127 @@ */ final class WP_CSS_Attribute_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { /** - * [att=val] - * Represents an element with the att attribute whose value is exactly "val". + * The attribute value is matched exactly. + * + * @example + * + * [att=val] */ - const MATCH_EXACT = 'MATCH_EXACT'; + const MATCH_EXACT = 'exact'; /** - * [attr~=value] - * Represents elements with an attribute name of attr whose value is a - * whitespace-separated list of words, one of which is exactly value. + * The attribute value matches any value in a whitespace separated list of words exactly. + * + * @example + * + * [attr~=value] */ - const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; + const MATCH_ONE_OF_EXACT = 'one-of'; /** - * [attr|=value] - * Represents elements with an attribute name of attr whose value can be exactly value or - * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for - * language subcode matches. + * The attribute value is matched exactly or matches the beginning of the attribute + * immediately followed by a hyphen. + * + * @example + * + * [attr|=value] */ - const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; + const MATCH_EXACT_OR_HYPHEN_PREFIXED = 'exact-or-hyphen-prefixed'; /** - * [attr^=value] - * Represents elements with an attribute name of attr whose value is prefixed (preceded) - * by value. + * The attribute value matches the start of the attribute. + * + * @example + * + * [attr^=value] */ - const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; + const MATCH_PREFIXED_BY = 'prefixed'; /** - * [attr$=value] - * Represents elements with an attribute name of attr whose value is suffixed (followed) - * by value. + * The attribute value matches the end of the attribute. + * + * @example + * + * [attr$=value] */ - const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; + const MATCH_SUFFIXED_BY = 'suffixed'; /** - * [attr*=value] - * Represents elements with an attribute name of attr whose value contains at least one - * occurrence of value within the string. + * The attribute value is contained in the attribute. + * + * @example + * + * [attr*=value] */ - const MATCH_CONTAINS = 'MATCH_CONTAINS'; + const MATCH_CONTAINS = 'contains'; /** - * Modifier for case sensitive matching - * [attr=value s] + * Modifier for case sensitive matching. + * + * @example + * + * [attr=value s] */ const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; /** - * Modifier for case insensitive matching - * [attr=value i] + * Modifier for case insensitive matching. + * + * @example + * + * [attr=value i] */ const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; /** - * The attribute name. + * The name of the attribute to match. * * @var string - * @readonly */ public $name; /** * The attribute matcher. * - * @var null|self::MATCH_* - * @readonly + * Allowed string values are the class constants: + * - {@see WP_CSS_Attribute_Selector::MATCH_EXACT} + * - {@see WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT} + * - {@see WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED} + * - {@see WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY} + * - {@see WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY} + * - {@see WP_CSS_Attribute_Selector::MATCH_CONTAINS} + * + * @var string|null */ public $matcher; /** - * The attribute value. + * The attribute value to match. * * @var string|null - * @readonly */ public $value; /** * The attribute modifier. * - * @var null|self::MODIFIER_* - * @readonly + * Allowed string values are the class constants: + * - {@see WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE} + * - {@see WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE} + * + * @var string|null */ public $modifier; /** * Constructor. * - * @param string $name - * @param null|self::MATCH_* $matcher - * @param null|string $value - * @param null|self::MODIFIER_* $modifier + * @param string $name The attribute name. + * @param string|null $matcher The attribute matcher. + * Must be one of the class MATCH_* constants or null. + * @param string|null $value The attribute value to match. + * @param string|null $modifier The attribute case modifier. + * Must be one of the class MODIFIER_* constants or null. */ public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; @@ -159,7 +188,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { } return false; - case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + case self::MATCH_EXACT_OR_HYPHEN_PREFIXED: // Attempt the full match first if ( $case_insensitive diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 27900d40a238c..02a958e647ef1 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -294,7 +294,7 @@ final protected static function parse_attribute_selector( string $input, int &$o $updated_offset += 2; break; case '|': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED; $updated_offset += 2; break; case '^': diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 19aad862db7e2..414d36301ec5d 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -1,6 +1,6 @@ array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), From ca1a12973819a7d7b3ea05b4bff160ced33532dd Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:04:11 +0100 Subject: [PATCH 111/336] Update matches docs --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 3 +-- src/wp-includes/html-api/class-wp-css-class-selector.php | 3 +-- src/wp-includes/html-api/class-wp-css-id-selector.php | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index b64efea0bb45c..fbdef5ce930be 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -10,8 +10,7 @@ /** * CSS attribute selector. * - * This class implements a CSS attribute selector and is used to test for matching HTML tags - * in a {@see WP_HTML_Tag_Processor}. + * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * * @since TBD * diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index cdd38d951e45c..02410546a4b52 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -10,8 +10,7 @@ /** * CSS class selector. * - * This class implements a CSS class selector and is used to test for matching HTML tags - * in a {@see WP_HTML_Tag_Processor}. + * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * * @since TBD * diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 5bb6438df6eb3..ca61f00bb7e67 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -10,8 +10,7 @@ /** * CSS ID selector. * - * This class implements a CSS ID selector and is used to test for matching HTML tags - * in a {@see WP_HTML_Tag_Processor}. + * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * * @since TBD * From 71fd62aa9d5f8342ed221ec674c420753b838e14 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:15:27 +0100 Subject: [PATCH 112/336] Document complex selector class --- .../class-wp-css-complex-selector.php | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index bd51884901d93..c6795254ea7b8 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -10,10 +10,10 @@ /** * CSS complex selector. * - * This class implements a CSS complex selector and is used to test for matching HTML tags - * in a {@see WP_HTML_Tag_Processor}. + * This class is used to test for matching HTML tags in a {@see WP_HTML_Processor}. * - * A complex selector is a selector with zero or more combinator-selector pairs. + * A compound selector is at least a single compound selector. There may be additional selectors + * with combinators. * * @since TBD * @@ -106,8 +106,10 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { public $context_selectors; /** - * @param WP_CSS_Compound_Selector $self_selector - * @param array{WP_CSS_Type_Selector, string}[]|null $selectors + * Constructor. + * + * @param WP_CSS_Compound_Selector $self_selector The selector in the final position. + * @param array{WP_CSS_Type_Selector, string}[]|null $selectors The context selectors. */ public function __construct( WP_CSS_Compound_Selector $self_selector, @@ -133,16 +135,15 @@ public function matches( WP_HTML_Processor $processor ): bool { return true; } - /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); return $this->explore_matches( $this->context_selectors, $breadcrumbs ); } /** - * Checks for matches recursively comparing context selectors with breadcrumbs. + * Checks for matches by recursively comparing context selectors with breadcrumbs. * - * @param array{WP_CSS_Type_Selector, string}[] $selectors - * @param string[] $breadcrumbs + * @param array{WP_CSS_Type_Selector, string}[] $selectors Selectors to match. + * @param string[] $breadcrumbs Breadcrumbs. * @return bool True if a match is found, otherwise false. */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { @@ -176,7 +177,16 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; default: - throw new Exception( "Unsupported combinator '{$combinator}' found." ); + _doing_it_wrong( + __METHOD__, + sprintf( + // translators: %s: A CSS selector combinator like ">" or "+". + __( 'Unsupported combinator "%s" found.' ), + $combinator + ), + '6.8.0' + ); + return false; } } } From 25dbb198cbfa081931b9a60dbda7e5f682d4b8d4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:23:28 +0100 Subject: [PATCH 113/336] PHP < 7.4 does not like this annotation --- .../html-api/class-wp-css-compound-selector-list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 02a958e647ef1..a74ffe0f45fc9 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -83,7 +83,7 @@ class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher * @param WP_HTML_Tag_Processor $processor The processor. * @return bool True if the processor's current position matches the selector. */ - public function matches( WP_HTML_Tag_Processor $processor ): bool { + public function matches( $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; } From 70cf7f7584b15ac2934dc90a86b030a21e2ad2d4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:47:52 +0100 Subject: [PATCH 114/336] Update since annotations to 6.8.0 --- .../html-api/class-wp-css-attribute-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-class-selector.php | 4 ++-- .../html-api/class-wp-css-complex-selector-list.php | 6 +++--- src/wp-includes/html-api/class-wp-css-complex-selector.php | 4 ++-- .../html-api/class-wp-css-compound-selector-list.php | 6 +++--- src/wp-includes/html-api/class-wp-css-compound-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-id-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-type-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-html-processor.php | 4 ++-- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ++-- tests/phpunit/tests/html-api/wpCssComplexSelectorList.php | 2 +- tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php | 2 +- tests/phpunit/tests/html-api/wpHtmlProcessor-select.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php | 2 +- 14 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index fbdef5ce930be..7543cb834e820 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index 02410546a4b52..fa287cdf5c580 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 4a9fc03f582f8..fcc6032589584 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -28,7 +28,7 @@ * - Next sibling (`el + el`) * - Subsequent sibling (`el ~ el`) * - * @since TBD + * @since 6.8.0 * * @access private */ @@ -37,7 +37,7 @@ class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List impleme * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. * - * @since TBD + * @since 6.8.0 * * @param string $input CSS selectors. * @return static|null diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index c6795254ea7b8..4461e4d7d92f3 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -15,7 +15,7 @@ * A compound selector is at least a single compound selector. There may be additional selectors * with combinators. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a74ffe0f45fc9..a2ff48e089f5d 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -67,7 +67,7 @@ * - `svg|*` to select all SVG elements * - `html|title` to select only HTML TITLE elements. * - * @since TBD + * @since 6.8.0 * * @access private * @@ -116,7 +116,7 @@ protected function __construct( array $selectors ) { * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. * - * @since TBD + * @since 6.8.0 * * @param string $input CSS selectors. * @return static|null diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 414d36301ec5d..9596876685212 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -17,7 +17,7 @@ * - Zero or more subclass selectors (ID, class, or attribute selectors). * - At least one of the above. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index ca61f00bb7e67..2a600923fa2a2 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index 66d6a1f2db48f..3f7671851c375 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since TBD + * @since 6.8.0 */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since TBD + * @since 6.8.0 * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 7ad65cb9d03d4..6685eaaf79aea 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -652,7 +652,7 @@ public function get_unsupported_exception() { * ); * } * - * @since TBD + * @since 6.8.0 * * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. @@ -692,7 +692,7 @@ public function select_all( $selector_string ): Generator { * $processor->get_attribute( 'charset' ), // string(5) "utf-8" * ); * - * @since TBD + * @since 6.8.0 * * @param string $selector_string * @return bool True if a matching tag was found, otherwise false. 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 a7633291b6bb2..8ea6e930f5b91 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 @@ -877,7 +877,7 @@ public function change_parsing_namespace( string $new_namespace ): bool { * ); * } * - * @since TBD + * @since 6.8.0 * * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. @@ -917,7 +917,7 @@ public function select_all( $selector_string ): Generator { * $processor->get_attribute( 'charset' ), // string(5) "utf-8" * ); * - * @since TBD + * @since 6.8.0 * * @param string $selector_string * @return bool True if a matching tag was found, otherwise false. diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 076d5b6f65ee6..829af95a55d5f 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since TBD + * @since 6.8.0 * * @group html-api */ diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 2c7a4695f679e..c112585e622c8 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since TBD + * @since 6.8.0 * * @group html-api */ diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 21828faf42e80..a8f6a7c949080 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -8,7 +8,7 @@ * @package WordPress * @subpackage HTML-API * - * @since TBD + * @since 6.8.0 * * @group html-api */ diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 586e38b4bafb2..28f88778629ce 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -8,7 +8,7 @@ * @package WordPress * @subpackage HTML-API * - * @since TBD + * @since 6.8.0 * * @group html-api */ From 355c9a24e0d983813ae73e8cacc59287833d2846 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Dec 2024 19:53:48 +0100 Subject: [PATCH 115/336] Update attr-modifier to match selectors grammar --- .../html-api/class-wp-css-compound-selector-list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a2ff48e089f5d..fa12519540cc5 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -36,7 +36,7 @@ * = '[' ']' | * '[' [ | ] ? ']' * = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * = i | I | s | S + * = i | s * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * From 3206e0b02f2d7f77cc52a0a3710f0d18ec73b9c3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 18:25:36 +0100 Subject: [PATCH 116/336] Move parsing back to selector classes --- .../class-wp-css-attribute-selector.php | 122 ++- .../html-api/class-wp-css-class-selector.php | 28 +- .../class-wp-css-complex-selector-list.php | 96 +-- .../class-wp-css-complex-selector.php | 80 +- .../class-wp-css-compound-selector-list.php | 735 +----------------- .../class-wp-css-compound-selector.php | 60 +- .../html-api/class-wp-css-id-selector.php | 24 +- .../class-wp-css-selector-parser-matcher.php | 476 ++++++++++++ .../html-api/class-wp-css-type-selector.php | 30 +- ...nterface-wp-css-html-processor-matcher.php | 11 - ...face-wp-css-html-tag-processor-matcher.php | 11 - 11 files changed, 839 insertions(+), 834 deletions(-) create mode 100644 src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php delete mode 100644 src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php delete mode 100644 src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 7543cb834e820..700a8cba9bb0c 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -16,7 +16,7 @@ * * @access private */ -final class WP_CSS_Attribute_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { /** * The attribute value is matched exactly. * @@ -244,4 +244,124 @@ private function whitespace_delimited_list( string $input ): Generator { yield $value; } } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + case 'I': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + case 'S': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } } diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index fa287cdf5c580..9abcb881ace49 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -16,7 +16,7 @@ * * @access private */ -final class WP_CSS_Class_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser_Matcher { /** * The class name to match. * @@ -42,4 +42,30 @@ public function __construct( string $class_name ) { public function matches( WP_HTML_Tag_Processor $processor ): bool { return (bool) $processor->has_class( $this->class_name ); } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return new self( $result ); + } } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index fcc6032589584..10af613174a35 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -32,17 +32,18 @@ * * @access private */ -class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { /** - * Takes a CSS selector string and returns an instance of itself or `null` if the selector - * string is invalid or unsupported. + * Parses a selector string to create a selector instance. * - * @since 6.8.0 + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. * - * @param string $input CSS selectors. - * @return static|null + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function from_selectors( string $input ) { + public static function parse( string $input, int &$offset ): ?static { $input = self::normalize_selector_input( $input ); if ( '' === $input ) { @@ -51,7 +52,7 @@ public static function from_selectors( string $input ) { $offset = 0; - $selector = self::parse_complex_selector( $input, $offset ); + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } @@ -65,7 +66,7 @@ public static function from_selectors( string $input ) { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = self::parse_complex_selector( $input, $offset ); + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } @@ -75,81 +76,4 @@ public static function from_selectors( string $input ) { return new self( $selectors ); } - - /* - * ------------------------------ - * Selector parsing functionality - * ------------------------------ - */ - - /** - * Parses a complex selector. - * - * > = [ ? ]* - * - * @return WP_CSS_Complex_Selector|null - */ - final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $self_selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $self_selector ) { - return null; - } - /** @var array{WP_CSS_Compound_Selector, string}[] */ - $selectors = array(); - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - while ( $updated_offset < strlen( $input ) ) { - $combinator = null; - $next_selector = null; - - if ( - WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] - ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // A combinator has been found, failure to find a selector here is a parse error. - $next_selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $next_selector ) { - return null; - } - } elseif ( $found_whitespace ) { - /* - * Whitespace is ambiguous, it could be a descendant combinator or - * insignificant whitespace. - */ - $next_selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null !== $next_selector ) { - $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; - } - } - - if ( null === $next_selector ) { - break; - } - - // $self_selector will pass to a relative selector where only the type selector is allowed. - if ( null !== $self_selector->subclass_selectors || null === $self_selector->type_selector ) { - return null; - } - - /** @var array{WP_CSS_Compound_Selector, string} */ - $selector_pair = array( $self_selector->type_selector, $combinator ); - $selectors[] = $selector_pair; - $self_selector = $next_selector; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - } - $offset = $updated_offset; - - return new WP_CSS_Complex_Selector( $self_selector, array_reverse( $selectors ) ); - } } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 4461e4d7d92f3..7c997c62a80f7 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -19,7 +19,7 @@ * * @access private */ -final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { +final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser_Matcher { /** * Child combinator. */ @@ -111,7 +111,7 @@ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { * @param WP_CSS_Compound_Selector $self_selector The selector in the final position. * @param array{WP_CSS_Type_Selector, string}[]|null $selectors The context selectors. */ - public function __construct( + private function __construct( WP_CSS_Compound_Selector $self_selector, ?array $context_selectors ) { @@ -125,7 +125,7 @@ public function __construct( * @param WP_HTML_Processor $processor The processor. * @return bool True if the processor's current position matches the selector. */ - public function matches( WP_HTML_Processor $processor ): bool { + public function matches( $processor ): bool { // First selector must match this location. if ( ! $this->self_selector->matches( $processor ) ) { return false; @@ -189,4 +189,78 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $self_selector = WP_CSS_Compound_Selector::parse( $input, $updated_offset ); + if ( null === $self_selector ) { + return null; + } + /** @var array{WP_CSS_Compound_Selector, string}[] */ + $selectors = array(); + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + $combinator = null; + $next_selector = null; + + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + + // A combinator has been found, failure to find a selector here is a parse error. + $next_selector = WP_CSS_Compound_Selector::parse( $input, $updated_offset ); + if ( null === $next_selector ) { + return null; + } + } elseif ( $found_whitespace ) { + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $next_selector = WP_CSS_Compound_Selector::parse( $input, $updated_offset ); + if ( null !== $next_selector ) { + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } + } + + if ( null === $next_selector ) { + break; + } + + // $self_selector will pass to a relative selector where only the type selector is allowed. + if ( null !== $self_selector->subclass_selectors || null === $self_selector->type_selector ) { + return null; + } + + /** @var array{WP_CSS_Compound_Selector, string} */ + $selector_pair = array( $self_selector->type_selector, $combinator ); + $selectors[] = $selector_pair; + $self_selector = $next_selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + + return new self( $self_selector, array_reverse( $selectors ) ); + } } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index fa12519540cc5..a6f3b87409ff6 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -76,7 +76,7 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { +class WP_CSS_Compound_Selector_List extends WP_CSS_Selector_Parser_Matcher { /** * Determines if the processor's current position matches the selector. * @@ -121,7 +121,22 @@ protected function __construct( array $selectors ) { * @param string $input CSS selectors. * @return static|null */ - public static function from_selectors( string $input ) { + public static function from_selectors( string $input ): ?static { + $offset = 0; + return static::parse( $input, $offset ); + } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { $input = self::normalize_selector_input( $input ); if ( '' === $input ) { @@ -130,7 +145,7 @@ public static function from_selectors( string $input ) { $offset = 0; - $selector = self::parse_compound_selector( $input, $offset ); + $selector = WP_CSS_Compound_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } @@ -144,7 +159,7 @@ public static function from_selectors( string $input ) { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = self::parse_compound_selector( $input, $offset ); + $selector = WP_CSS_Compound_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } @@ -154,716 +169,4 @@ public static function from_selectors( string $input ) { return new self( $selectors ); } - - /* - * ------------------------------ - * Selector parsing functionality - * ------------------------------ - */ - - /** - * Parse an ID selector - * - * > = - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return WP_CSS_ID_Selector|null - */ - final protected static function parse_id_selector( string $input, int &$offset ): ?WP_CSS_ID_Selector { - $ident = self::parse_hash_token( $input, $offset ); - if ( null === $ident ) { - return null; - } - return new WP_CSS_ID_Selector( $ident ); - } - - /** - * Parse a class selector - * - * > = '.' - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return WP_CSS_Class_Selector|null - */ - final protected static function parse_class_selector( string $input, int &$offset ): ?WP_CSS_Class_Selector { - if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { - return null; - } - - $updated_offset = $offset + 1; - $result = self::parse_ident( $input, $updated_offset ); - - if ( null === $result ) { - return null; - } - - $offset = $updated_offset; - return new WP_CSS_Class_Selector( $result ); - } - - /** - * Parse a type selector - * - * > = | ? '*' - * > = [ | '*' ]? '|' - * > = ? - * - * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, - * so this selector effectively matches * or ident. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return WP_CSS_Type_Selector|null - */ - final protected static function parse_type_selector( string $input, int &$offset ): ?WP_CSS_Type_Selector { - if ( $offset >= strlen( $input ) ) { - return null; - } - - if ( '*' === $input[ $offset ] ) { - ++$offset; - return new WP_CSS_Type_Selector( '*' ); - } - - $result = self::parse_ident( $input, $offset ); - if ( null === $result ) { - return null; - } - - return new WP_CSS_Type_Selector( $result ); - } - - /** - * Parse an attribute selector - * - * > = '[' ']' | - * > '[' [ | ] ? ']' - * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * > = i | s - * > = ? - * - * Namespaces are not supported, so attribute names are effectively identifiers. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return WP_CSS_Attribute_Selector|null - */ - final protected static function parse_attribute_selector( string $input, int &$offset ): ?WP_CSS_Attribute_Selector { - // Need at least 3 bytes [x] - if ( $offset + 2 >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - - if ( '[' !== $input[ $updated_offset ] ) { - return null; - } - ++$updated_offset; - - self::parse_whitespace( $input, $updated_offset ); - $attr_name = self::parse_ident( $input, $updated_offset ); - if ( null === $attr_name ) { - return null; - } - self::parse_whitespace( $input, $updated_offset ); - - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new WP_CSS_Attribute_Selector( $attr_name ); - } - - // need to match at least `=x]` at this point - if ( $updated_offset + 3 >= strlen( $input ) ) { - return null; - } - - if ( '=' === $input[ $updated_offset ] ) { - ++$updated_offset; - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; - } elseif ( '=' === $input[ $updated_offset + 1 ] ) { - switch ( $input[ $updated_offset ] ) { - case '~': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; - $updated_offset += 2; - break; - case '|': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED; - $updated_offset += 2; - break; - case '^': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; - $updated_offset += 2; - break; - case '$': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; - $updated_offset += 2; - break; - case '*': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; - $updated_offset += 2; - break; - default: - return null; - } - } else { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - $attr_val = - self::parse_string( $input, $updated_offset ) ?? - self::parse_ident( $input, $updated_offset ); - - if ( null === $attr_val ) { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - $attr_modifier = null; - switch ( $input[ $updated_offset ] ) { - case 'i': - case 'I': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; - ++$updated_offset; - break; - - case 's': - case 'S': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; - ++$updated_offset; - break; - } - - if ( null !== $attr_modifier ) { - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new WP_CSS_Attribute_Selector( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); - } - - return null; - } - - /** - * Parses a compound selector. - * - * > = [ ? * ]! - * - * @return WP_CSS_Compound_Selector|null - */ - final protected static function parse_compound_selector( string $input, int &$offset ): ?WP_CSS_Compound_Selector { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $type_selector = self::parse_type_selector( $input, $updated_offset ); - - $subclass_selectors = array(); - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - while ( null !== $last_parsed_subclass_selector ) { - $subclass_selectors[] = $last_parsed_subclass_selector; - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - } - - if ( null !== $type_selector || array() !== $subclass_selectors ) { - $offset = $updated_offset; - return new WP_CSS_Compound_Selector( $type_selector, $subclass_selectors ); - } - return null; - } - - /** - * Parses a subclass selector. - * - * > = | | - * - * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null - */ - private static function parse_subclass_selector( string $input, int &$offset ) { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $next_char = $input[ $offset ]; - return '.' === $next_char - ? self::parse_class_selector( $input, $offset ) - : ( - '#' === $next_char - ? self::parse_id_selector( $input, $offset ) - : ( '[' === $next_char - ? self::parse_attribute_selector( $input, $offset ) - : null - ) - ); - } - - - /* - * ------------------------ - * Selector partial parsing - * ------------------------ - * - * These functions consume parts of a selector string input when successful - * and return meaningful values to be used by selectors. - */ - - const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; - const WHITESPACE_CHARACTERS = " \t\r\n\f"; - - final public static function parse_whitespace( string $input, int &$offset ): bool { - $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); - $advanced = $length > 0; - $offset += $length; - return $advanced; - } - - /** - * Tokenization of hash tokens - * - * > U+0023 NUMBER SIGN (#) - * > If the next input code point is an ident code point or the next two input code points are a valid escape, then: - * > 1. Create a . - * > 2. If the next 3 input code points would start an ident sequence, set the - * > ’s type flag to "id". - * > 3. Consume an ident sequence, and set the ’s value to the - * > returned string. - * > 4. Return the . - * > Otherwise, return a with its value set to the current input code point. - * - * This implementation is not interested in the , a '#' delim token is not relevant for selectors. - */ - final protected static function parse_hash_token( string $input, int &$offset ): ?string { - if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { - return null; - } - - $updated_offset = $offset + 1; - $result = self::parse_ident( $input, $updated_offset ); - - if ( null === $result ) { - return null; - } - - $offset = $updated_offset; - return $result; - } - - /** - * Parse an ident token - * - * CAUTION: This method is _not_ for parsing and ID selector! - * - * > 4.3.11. Consume an ident sequence - * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. - * > - * > Note: This algorithm does not do the verification of the first few code points that are necessary to ensure the returned code points would constitute an . If that is the intended use, ensure that the stream starts with an ident sequence before calling this algorithm. - * > - * > Let result initially be an empty string. - * > - * > Repeatedly consume the next input code point from the stream: - * > - * > ident code point - * > Append the code point to result. - * > the stream starts with a valid escape - * > Consume an escaped code point. Append the returned code point to result. - * > anything else - * > Reconsume the current input code point. Return result. - * - * https://www.w3.org/TR/css-syntax-3/#consume-name - * - * @return string|null - */ - final protected static function parse_ident( string $input, int &$offset ): ?string { - if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { - return null; - } - - $ident = ''; - - while ( $offset < strlen( $input ) ) { - if ( self::next_two_are_valid_escape( $input, $offset ) ) { - // Move past the `\` character. - ++$offset; - $ident .= self::consume_escaped_codepoint( $input, $offset ); - continue; - } elseif ( self::is_ident_codepoint( $input, $offset ) ) { - // @todo this should append and advance the correct number of bytes. - $ident .= $input[ $offset ]; - ++$offset; - continue; - } - break; - } - - return $ident; - } - - /** - * Parse a string token - * - * > 4.3.5. Consume a string token - * > This section describes how to consume a string token from a stream of code points. It returns either a or . - * > - * > This algorithm may be called with an ending code point, which denotes the code point that ends the string. If an ending code point is not specified, the current input code point is used. - * > - * > Initially create a with its value set to the empty string. - * > - * > Repeatedly consume the next input code point from the stream: - * > - * > ending code point - * > Return the . - * > EOF - * > This is a parse error. Return the . - * > newline - * > This is a parse error. Reconsume the current input code point, create a , and return it. - * > U+005C REVERSE SOLIDUS (\) - * > If the next input code point is EOF, do nothing. - * > Otherwise, if the next input code point is a newline, consume it. - * > Otherwise, (the stream starts with a valid escape) consume an escaped code point and append the returned code point to the ’s value. - * > - * > anything else - * > Append the current input code point to the ’s value. - * - * https://www.w3.org/TR/css-syntax-3/#consume-string-token - * - * This implementation will never return a because - * the is not a part of the selector grammar. That - * case is treated as failure to parse and null is returned. - * - * @return string|null - */ - final protected static function parse_string( string $input, int &$offset ): ?string { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $ending_code_point = $input[ $offset ]; - if ( '"' !== $ending_code_point && "'" !== $ending_code_point ) { - return null; - } - - $string_token = ''; - - $updated_offset = $offset + 1; - $anything_else_mask = "\\\n{$ending_code_point}"; - while ( $updated_offset < strlen( $input ) ) { - $anything_else_length = strcspn( $input, $anything_else_mask, $updated_offset ); - if ( $anything_else_length > 0 ) { - $string_token .= substr( $input, $updated_offset, $anything_else_length ); - $updated_offset += $anything_else_length; - - if ( $updated_offset >= strlen( $input ) ) { - break; - } - } - - switch ( $input[ $updated_offset ] ) { - case '\\': - ++$updated_offset; - if ( $updated_offset >= strlen( $input ) ) { - break; - } - if ( "\n" === $input[ $updated_offset ] ) { - ++$updated_offset; - break; - } else { - $string_token .= self::consume_escaped_codepoint( $input, $updated_offset ); - } - break; - - /* - * This case would return a . - * The is not a part of the selector grammar - * so we do not return it and instead treat this as a - * failure to parse a string token. - */ - case "\n": - return null; - - case $ending_code_point: - ++$updated_offset; - break 2; - } - } - - $offset = $updated_offset; - return $string_token; - } - - /** - * Consume an escaped code point. - * - * > 4.3.7. Consume an escaped code point - * > This section describes how to consume an escaped code point. It assumes that the U+005C - * > REVERSE SOLIDUS (\) has already been consumed and that the next input code point has - * > already been verified to be part of a valid escape. It will return a code point. - * > - * > Consume the next input code point. - * > - * > hex digit - * > Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 - * > hex digits have been consumed in total. If the next input code point is whitespace, - * > consume it as well. Interpret the hex digits as a hexadecimal number. If this number is - * > zero, or is for a surrogate, or is greater than the maximum allowed code point, return - * > U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point with that value. - * > EOF - * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). - * > anything else - * > Return the current input code point. - * - * @param string $input - * @param int $offset - * @return string - */ - final protected static function consume_escaped_codepoint( $input, &$offset ): string { - $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); - if ( $hex_length > 0 ) { - /** - * The 6-character hex string has a maximum value of 0xFFFFFF. - * It is likely to fit in an int value and not be a float. - * - * @var int - */ - $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); - - /* - * > A surrogate is a leading surrogate or a trailing surrogate. - * > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. - * > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. - * - * The surrogate ranges are adjacent, so the complete range is 0xD800 to 0xDFFF, inclusive. - */ - $codepoint_char = ( - 0 === $codepoint_value || - $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || - ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) - ) - ? "\u{FFFD}" - : mb_chr( $codepoint_value, 'UTF-8' ); - - $offset += $hex_length; - - // If the next input code point is whitespace, consume it as well. - if ( - strlen( $input ) > $offset && - ( - "\n" === $input[ $offset ] || - "\t" === $input[ $offset ] || - ' ' === $input[ $offset ] - ) - ) { - ++$offset; - } - return $codepoint_char; - } - - $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); - $offset += strlen( $codepoint_char ); - return $codepoint_char; - } - - /* - * --------------------------- - * Selector parsing utiltities - * --------------------------- - * - * The following functions are used for parsing but do not consume any input. - */ - - /** - * Checks for two valid escape codepoints. - * - * > 4.3.8. Check if two code points are a valid escape - * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. - * > - * > Note: This algorithm will not consume any additional code point. - * > - * > If the first code point is not U+005C REVERSE SOLIDUS (\), return false. - * > - * > Otherwise, if the second code point is a newline, return false. - * > - * > Otherwise, return true. - * - * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape - * - * @todo this does not check whether the second codepoint is valid. - * - * @param string $input The input string. - * @param int $offset The byte offset in the string. - * @return bool True if the next two codepoints are a valid escape, otherwise false. - */ - final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { - if ( $offset + 1 >= strlen( $input ) ) { - return false; - } - return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; - } - - /** - * Checks if the next code point is an "ident start code point". - * - * Caution! This method does not do any bounds checking, it should not be passed - * a string with an offset that is out of bounds. - * - * > ident-start code point - * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). - * > uppercase letter - * > A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. - * > lowercase letter - * > A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. - * > letter - * > An uppercase letter or a lowercase letter. - * > non-ASCII code point - * > A code point with a value equal to or greater than U+0080 . - * - * @link https://www.w3.org/TR/css-syntax-3/#ident-start-code-point - * - * @param string $input The input string. - * @param int $offset The byte offset in the string. - * @return bool True if the next codepoint is an ident start code point, otherwise false. - */ - final protected static function is_ident_start_codepoint( string $input, int $offset ): bool { - return ( - '_' === $input[ $offset ] || - ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || - ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || - ord( $input[ $offset ] ) > 0x7F - ); - } - - /** - * Checks if the next code point is an "ident code point". - * - * Caution! This method does not do any bounds checking, it should not be passed - * a string with an offset that is out of bounds. - * - * > ident code point - * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). - * > digit - * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. - * - * @link https://www.w3.org/TR/css-syntax-3/#ident-code-point - * - * @param string $input The input string. - * @param int $offset The byte offset in the string. - * @return bool True if the next codepoint is an ident code point, otherwise false. - */ - final protected static function is_ident_codepoint( string $input, int $offset ): bool { - return '-' === $input[ $offset ] || - ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || - self::is_ident_start_codepoint( $input, $offset ); - } - - /** - * Checks if three code points would start an ident sequence. - * - * > 4.3.9. Check if three code points would start an ident sequence - * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. - * > - * > Note: This algorithm will not consume any additional code points. - * > - * > Look at the first code point: - * > - * > U+002D HYPHEN-MINUS - * > If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, or the second and third code points are a valid escape, return true. Otherwise, return false. - * > ident-start code point - * > Return true. - * > U+005C REVERSE SOLIDUS (\) - * > If the first and second code points are a valid escape, return true. Otherwise, return false. - * > anything else - * > Return false. - * - * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier - * - * @param string $input The input string. - * @param int $offset The byte offset in the string. - * @return bool True if the next three codepoints would start an ident sequence, otherwise false. - */ - final protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { - if ( $offset >= strlen( $input ) ) { - return false; - } - - // > U+005C REVERSE SOLIDUS (\) - if ( '\\' === $input[ $offset ] ) { - return self::next_two_are_valid_escape( $input, $offset ); - } - - // > U+002D HYPHEN-MINUS - if ( '-' === $input[ $offset ] ) { - $after_initial_hyphen_minus_offset = $offset + 1; - if ( $after_initial_hyphen_minus_offset >= strlen( $input ) ) { - return false; - } - - // > If the second code point is… U+002D HYPHEN-MINUS… return true - if ( '-' === $input[ $after_initial_hyphen_minus_offset ] ) { - return true; - } - - // > If the second and third code points are a valid escape… return true. - if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { - return true; - } - - // > If the second code point is an ident-start code point… return true. - if ( self::is_ident_start_codepoint( $input, $after_initial_hyphen_minus_offset ) ) { - return true; - } - - // > Otherwise, return false. - return false; - } - - // > ident-start code point - // > Return true. - // > anything else - // > Return false. - return self::is_ident_start_codepoint( $input, $offset ); - } - - /** - * @todo doc… - */ - final protected static function normalize_selector_input( string $input ): string { - /* - * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… - * - * This list includes \f. - * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. - */ - $input = trim( $input, " \t\r\n\r\f" ); - - /* - * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. - * > - * > To filter code points from a stream of (unfiltered) code points input: - * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. - * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). - * - * https://www.w3.org/TR/css-syntax-3/#input-preprocessing - */ - $input = str_replace( array( "\r\n" ), "\n", $input ); - $input = str_replace( array( "\r", "\f" ), "\n", $input ); - $input = str_replace( "\0", "\u{FFFD}", $input ); - - return $input; - } } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 9596876685212..68aca4d880e0d 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -21,7 +21,7 @@ * * @access private */ -final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Compound_Selector extends WP_CSS_Selector_Parser_Matcher { /** * The type selector. * @@ -69,4 +69,62 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { } return true; } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = WP_CSS_Type_Selector::parse( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + // @todo invert this condition + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new self( $type_selector, $subclass_selectors ); + } + return null; + } + + /** + * Parses a subclass selector. + * + * > = | | + * + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char + ? WP_CSS_Class_Selector::parse( $input, $offset ) + : ( + '#' === $next_char + ? WP_CSS_ID_Selector::parse( $input, $offset ) + : ( '[' === $next_char + ? WP_CSS_Attribute_Selector::parse( $input, $offset ) + : null + ) + ); + } } diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 2a600923fa2a2..de854c37eea9f 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -16,7 +16,7 @@ * * @access private */ -final class WP_CSS_ID_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser_Matcher { /** * The ID to match. * @@ -48,7 +48,25 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { $case_insensitive = $processor->is_quirks_mode(); return $case_insensitive - ? 0 === strcasecmp( $id, $this->id ) - : $processor->get_attribute( 'id' ) === $this->id; + ? 0 === strcasecmp( $id, $this->id ) + : $processor->get_attribute( 'id' ) === $this->id; + } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new self( $ident ); } } diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php new file mode 100644 index 0000000000000..8820115f03cfb --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -0,0 +1,476 @@ + 0; + $offset += $length; + return $advanced; + } + + /** + * Tokenization of hash tokens + * + * > U+0023 NUMBER SIGN (#) + * > If the next input code point is an ident code point or the next two input code points are a valid escape, then: + * > 1. Create a . + * > 2. If the next 3 input code points would start an ident sequence, set the + * > ’s type flag to "id". + * > 3. Consume an ident sequence, and set the ’s value to the + * > returned string. + * > 4. Return the . + * > Otherwise, return a with its value set to the current input code point. + * + * This implementation is not interested in the , a '#' delim token is not relevant for selectors. + */ + final protected static function parse_hash_token( string $input, int &$offset ): ?string { + if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return $result; + } + + /** + * Parse a string token + * + * > 4.3.5. Consume a string token + * > This section describes how to consume a string token from a stream of code points. It returns either a or . + * > + * > This algorithm may be called with an ending code point, which denotes the code point that ends the string. If an ending code point is not specified, the current input code point is used. + * > + * > Initially create a with its value set to the empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ending code point + * > Return the . + * > EOF + * > This is a parse error. Return the . + * > newline + * > This is a parse error. Reconsume the current input code point, create a , and return it. + * > U+005C REVERSE SOLIDUS (\) + * > If the next input code point is EOF, do nothing. + * > Otherwise, if the next input code point is a newline, consume it. + * > Otherwise, (the stream starts with a valid escape) consume an escaped code point and append the returned code point to the ’s value. + * > + * > anything else + * > Append the current input code point to the ’s value. + * + * https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * This implementation will never return a because + * the is not a part of the selector grammar. That + * case is treated as failure to parse and null is returned. + * + * @return string|null + */ + final protected static function parse_string( string $input, int &$offset ): ?string { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $ending_code_point = $input[ $offset ]; + if ( '"' !== $ending_code_point && "'" !== $ending_code_point ) { + return null; + } + + $string_token = ''; + + $updated_offset = $offset + 1; + $anything_else_mask = "\\\n{$ending_code_point}"; + while ( $updated_offset < strlen( $input ) ) { + $anything_else_length = strcspn( $input, $anything_else_mask, $updated_offset ); + if ( $anything_else_length > 0 ) { + $string_token .= substr( $input, $updated_offset, $anything_else_length ); + $updated_offset += $anything_else_length; + + if ( $updated_offset >= strlen( $input ) ) { + break; + } + } + + switch ( $input[ $updated_offset ] ) { + case '\\': + ++$updated_offset; + if ( $updated_offset >= strlen( $input ) ) { + break; + } + if ( "\n" === $input[ $updated_offset ] ) { + ++$updated_offset; + break; + } else { + $string_token .= self::consume_escaped_codepoint( $input, $updated_offset ); + } + break; + + /* + * This case would return a . + * The is not a part of the selector grammar + * so we do not return it and instead treat this as a + * failure to parse a string token. + */ + case "\n": + return null; + + case $ending_code_point: + ++$updated_offset; + break 2; + } + } + + $offset = $updated_offset; + return $string_token; + } + + /** + * Consume an escaped code point. + * + * > 4.3.7. Consume an escaped code point + * > This section describes how to consume an escaped code point. It assumes that the U+005C + * > REVERSE SOLIDUS (\) has already been consumed and that the next input code point has + * > already been verified to be part of a valid escape. It will return a code point. + * > + * > Consume the next input code point. + * > + * > hex digit + * > Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 + * > hex digits have been consumed in total. If the next input code point is whitespace, + * > consume it as well. Interpret the hex digits as a hexadecimal number. If this number is + * > zero, or is for a surrogate, or is greater than the maximum allowed code point, return + * > U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point with that value. + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + * > anything else + * > Return the current input code point. + * + * @param string $input + * @param int $offset + * @return string + */ + final protected static function consume_escaped_codepoint( $input, &$offset ): string { + $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); + if ( $hex_length > 0 ) { + /** + * The 6-character hex string has a maximum value of 0xFFFFFF. + * It is likely to fit in an int value and not be a float. + * + * @var int + */ + $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); + + /* + * > A surrogate is a leading surrogate or a trailing surrogate. + * > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + * > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + * + * The surrogate ranges are adjacent, so the complete range is 0xD800 to 0xDFFF, inclusive. + */ + $codepoint_char = ( + 0 === $codepoint_value || + $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || + ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) + ) + ? "\u{FFFD}" + : mb_chr( $codepoint_value, 'UTF-8' ); + + $offset += $hex_length; + + // If the next input code point is whitespace, consume it as well. + if ( + strlen( $input ) > $offset && + ( + "\n" === $input[ $offset ] || + "\t" === $input[ $offset ] || + ' ' === $input[ $offset ] + ) + ) { + ++$offset; + } + return $codepoint_char; + } + + $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); + $offset += strlen( $codepoint_char ); + return $codepoint_char; + } + + /** + * Parse an ident token + * + * CAUTION: This method is _not_ for parsing and ID selector! + * + * > 4.3.11. Consume an ident sequence + * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. + * > + * > Note: This algorithm does not do the verification of the first few code points that are necessary to ensure the returned code points would constitute an . If that is the intended use, ensure that the stream starts with an ident sequence before calling this algorithm. + * > + * > Let result initially be an empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ident code point + * > Append the code point to result. + * > the stream starts with a valid escape + * > Consume an escaped code point. Append the returned code point to result. + * > anything else + * > Reconsume the current input code point. Return result. + * + * https://www.w3.org/TR/css-syntax-3/#consume-name + * + * @return string|null + */ + final protected static function parse_ident( string $input, int &$offset ): ?string { + if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { + return null; + } + + $ident = ''; + + while ( $offset < strlen( $input ) ) { + if ( self::next_two_are_valid_escape( $input, $offset ) ) { + // Move past the `\` character. + ++$offset; + $ident .= self::consume_escaped_codepoint( $input, $offset ); + continue; + } elseif ( self::is_ident_codepoint( $input, $offset ) ) { + // @todo this should append and advance the correct number of bytes. + $ident .= $input[ $offset ]; + ++$offset; + continue; + } + break; + } + + return $ident; + } + + /* + * -------------------------- + * Selector parsing utilities + * -------------------------- + * + * The following functions are used for parsing but do not consume any input. + */ + + /** + * Checks for two valid escape codepoints. + * + * > 4.3.8. Check if two code points are a valid escape + * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. + * > + * > Note: This algorithm will not consume any additional code point. + * > + * > If the first code point is not U+005C REVERSE SOLIDUS (\), return false. + * > + * > Otherwise, if the second code point is a newline, return false. + * > + * > Otherwise, return true. + * + * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape + * + * @todo this does not check whether the second codepoint is valid. + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next two codepoints are a valid escape, otherwise false. + */ + final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { + if ( $offset + 1 >= strlen( $input ) ) { + return false; + } + return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; + } + + /** + * Checks if the next code point is an "ident start code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * + * > ident-start code point + * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). + * > uppercase letter + * > A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. + * > lowercase letter + * > A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. + * > letter + * > An uppercase letter or a lowercase letter. + * > non-ASCII code point + * > A code point with a value equal to or greater than U+0080 . + * + * @link https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident start code point, otherwise false. + */ + final protected static function is_ident_start_codepoint( string $input, int $offset ): bool { + return ( + '_' === $input[ $offset ] || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || + ord( $input[ $offset ] ) > 0x7F + ); + } + + /** + * Checks if the next code point is an "ident code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * + * > ident code point + * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + * > digit + * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + * + * @link https://www.w3.org/TR/css-syntax-3/#ident-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident code point, otherwise false. + */ + final protected static function is_ident_codepoint( string $input, int $offset ): bool { + return '-' === $input[ $offset ] || + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * Checks if three code points would start an ident sequence. + * + * > 4.3.9. Check if three code points would start an ident sequence + * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. + * > + * > Note: This algorithm will not consume any additional code points. + * > + * > Look at the first code point: + * > + * > U+002D HYPHEN-MINUS + * > If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, or the second and third code points are a valid escape, return true. Otherwise, return false. + * > ident-start code point + * > Return true. + * > U+005C REVERSE SOLIDUS (\) + * > If the first and second code points are a valid escape, return true. Otherwise, return false. + * > anything else + * > Return false. + * + * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next three codepoints would start an ident sequence, otherwise false. + */ + final protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > U+005C REVERSE SOLIDUS (\) + if ( '\\' === $input[ $offset ] ) { + return self::next_two_are_valid_escape( $input, $offset ); + } + + // > U+002D HYPHEN-MINUS + if ( '-' === $input[ $offset ] ) { + $after_initial_hyphen_minus_offset = $offset + 1; + if ( $after_initial_hyphen_minus_offset >= strlen( $input ) ) { + return false; + } + + // > If the second code point is… U+002D HYPHEN-MINUS… return true + if ( '-' === $input[ $after_initial_hyphen_minus_offset ] ) { + return true; + } + + // > If the second and third code points are a valid escape… return true. + if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > If the second code point is an ident-start code point… return true. + if ( self::is_ident_start_codepoint( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > Otherwise, return false. + return false; + } + + // > ident-start code point + // > Return true. + // > anything else + // > Return false. + return self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * @todo doc… + */ + final protected static function normalize_selector_input( string $input ): string { + /* + * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + * + * This list includes \f. + * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. + */ + $input = trim( $input, " \t\r\n\r\f" ); + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + return $input; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index 3f7671851c375..492569ee51d65 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -16,7 +16,7 @@ * * @access private */ -final class WP_CSS_Type_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser_Matcher { /** * The element type (tag name) to match or '*' to match any element. * @@ -59,4 +59,32 @@ public function matches_tag( string $tag_name ): bool { } return 0 === strcasecmp( $tag_name, $this->type ); } + + /** + * Parses a selector string to create a selector instance. + * + * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and + * will be updated if the parse is successful. + * @return static|null The selector instance, or null if the parse was unsuccessful. + */ + public static function parse( string $input, int &$offset ): ?static { + if ( $offset >= strlen( $input ) ) { + return null; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new WP_CSS_Type_Selector( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new self( $result ); + } } diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php deleted file mode 100644 index b77ef40931d83..0000000000000 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ /dev/null @@ -1,11 +0,0 @@ - Date: Wed, 11 Dec 2024 18:44:24 +0100 Subject: [PATCH 117/336] Update tests for class parsing --- .../tests/html-api/wpCssAttributeSelector.php | 90 ++++ .../tests/html-api/wpCssClassSelector.php | 49 +++ .../tests/html-api/wpCssComplexSelector.php | 71 ++++ .../html-api/wpCssComplexSelectorList.php | 73 +--- .../tests/html-api/wpCssCompoundSelector.php | 44 ++ .../html-api/wpCssCompoundSelectorList.php | 395 +----------------- .../tests/html-api/wpCssIdSelector.php | 50 +++ .../html-api/wpCssSelectorParserMatcher.php | 172 ++++++++ .../tests/html-api/wpCssTypeSelector.php | 51 +++ 9 files changed, 532 insertions(+), 463 deletions(-) create mode 100644 tests/phpunit/tests/html-api/wpCssAttributeSelector.php create mode 100644 tests/phpunit/tests/html-api/wpCssClassSelector.php create mode 100644 tests/phpunit/tests/html-api/wpCssComplexSelector.php create mode 100644 tests/phpunit/tests/html-api/wpCssCompoundSelector.php create mode 100644 tests/phpunit/tests/html-api/wpCssIdSelector.php create mode 100644 tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php create mode 100644 tests/phpunit/tests/html-api/wpCssTypeSelector.php diff --git a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php new file mode 100644 index 0000000000000..d907ad7c07e5b --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php @@ -0,0 +1,90 @@ +assertNull( $result ); + } else { + $this->assertSame( $expected_name, $result->name ); + $this->assertSame( $expected_matcher, $result->matcher ); + $this->assertSame( $expected_value, $result->value ); + $this->assertSame( $expected_modifier, $result->modifier ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_attribute_selectors(): array { + return array( + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssClassSelector.php b/tests/phpunit/tests/html-api/wpCssClassSelector.php new file mode 100644 index 0000000000000..fa1d097a5ad3d --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssClassSelector.php @@ -0,0 +1,49 @@ +assertNull( $result ); + } else { + $this->assertSame( $expected, $result->class_name ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_class_selectors(): array { + return array( + 'valid ._-foo123' => array( '._-foo123', '_-foo123', '' ), + 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), + 'escaped .\31 23' => array( '.\\31 23', '123', '' ), + 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), + + 'not class foo' => array( 'foo' ), + 'not class #bar' => array( '#bar' ), + 'not valid .1foo' => array( '.1foo' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelector.php b/tests/phpunit/tests/html-api/wpCssComplexSelector.php new file mode 100644 index 0000000000000..bb7b6e67e9d1a --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssComplexSelector.php @@ -0,0 +1,71 @@ + .child#bar[baz=quux] , rest'; + $offset = 0; + + /** @var WP_CSS_Complex_Selector|null */ + $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); + + $this->assertSame( 2, count( $sel->context_selectors ) ); + + // Relative selectors should be reverse ordered. + $this->assertSame( 'el2', $sel->context_selectors[0][0]->type ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->context_selectors[0][1] ); + + $this->assertSame( 'el1', $sel->context_selectors[1][0]->type ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->context_selectors[1][1] ); + + $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); + $this->assertNull( $sel->self_selector->type_selector ); + $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->class_name ); + + $this->assertSame( ', rest', substr( $input, $offset ) ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_complex_selector_nonfinal_subclass() { + $input = 'el.foo#bar[baz=quux] > final, rest'; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 829af95a55d5f..4e788860ff53f 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -9,79 +9,10 @@ * @since 6.8.0 * * @group html-api + * + * @coversDefaultClass WP_CSS_Complex_Selector_List */ class Tests_HtmlApi_WpCssComplexSelectorList extends WP_UnitTestCase { - private $test_class; - - public function set_up(): void { - parent::set_up(); - $this->test_class = new class() extends WP_CSS_Complex_Selector_List { - public function __construct() { - parent::__construct( array() ); - } - - public static function test_parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { - return self::parse_complex_selector( $input, $offset ); - } - }; - } - - /** - * @ticket 62653 - */ - public function test_parse_complex_selector() { - $input = 'el1 el2 > .child#bar[baz=quux] , rest'; - $offset = 0; - - /** @var WP_CSS_Complex_Selector|null */ - $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - - $this->assertSame( 2, count( $sel->context_selectors ) ); - - // Relative selectors should be reverse ordered. - $this->assertSame( 'el2', $sel->context_selectors[0][0]->type ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->context_selectors[0][1] ); - - $this->assertSame( 'el1', $sel->context_selectors[1][0]->type ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->context_selectors[1][1] ); - - $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); - $this->assertNull( $sel->self_selector->type_selector ); - $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->class_name ); - - $this->assertSame( ', rest', substr( $input, $offset ) ); - } - - /** - * @ticket 62653 - */ - public function test_parse_invalid_complex_selector() { - $input = 'el.foo#bar[baz=quux] > , rest'; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - /** - * @ticket 62653 - */ - public function test_parse_invalid_complex_selector_nonfinal_subclass() { - $input = 'el.foo#bar[baz=quux] > final, rest'; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - /** - * @ticket 62653 - */ - public function test_parse_empty_complex_selector() { - $input = ''; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - /** * @ticket 62653 */ diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelector.php b/tests/phpunit/tests/html-api/wpCssCompoundSelector.php new file mode 100644 index 0000000000000..8800c89d6ed36 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelector.php @@ -0,0 +1,44 @@ + .child'; + $offset = 0; + $sel = WP_CSS_Compound_Selector::parse( $input, $offset ); + + $this->assertSame( 'el', $sel->type_selector->type ); + $this->assertSame( 3, count( $sel->subclass_selectors ) ); + $this->assertSame( 'foo', $sel->subclass_selectors[0]->class_name, 'foo' ); + $this->assertSame( 'bar', $sel->subclass_selectors[1]->id, 'bar' ); + $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); + $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); + $this->assertSame( ' > .child', substr( $input, $offset ) ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Compound_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + $this->assertSame( 0, $offset ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index c112585e622c8..01eff118a87b0 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -1,6 +1,6 @@ test_class = new class() extends WP_CSS_Compound_Selector_List { - public function __construct() { - parent::__construct( array() ); - } - - /* - * Parsing - */ - public static function test_parse_ident( string $input, int &$offset ) { - return self::parse_ident( $input, $offset ); - } - - public static function test_parse_string( string $input, int &$offset ) { - return self::parse_string( $input, $offset ); - } - - public static function test_parse_type_selector( string $input, int &$offset ) { - return self::parse_type_selector( $input, $offset ); - } - - public static function test_parse_id_selector( string $input, int &$offset ) { - return self::parse_id_selector( $input, $offset ); - } - - public static function test_parse_class_selector( string $input, int &$offset ) { - return self::parse_class_selector( $input, $offset ); - } - - public static function test_parse_attribute_selector( string $input, int &$offset ) { - return self::parse_attribute_selector( $input, $offset ); - } - - public static function test_parse_compound_selector( string $input, int &$offset ) { - return self::parse_compound_selector( $input, $offset ); - } - - /* - * Utilities - */ - public static function test_is_ident_codepoint( string $input, int $offset ) { - return self::is_ident_codepoint( $input, $offset ); - } - - public static function test_is_ident_start_codepoint( string $input, int $offset ) { - return self::is_ident_start_codepoint( $input, $offset ); - } - }; - } - - /** - * Data provider. - * - * @return array - */ - public static function data_idents(): array { - return array( - 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), - 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), - 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), - 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), - 'escaped space' => array( '\\ x', ' x', '' ), - 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), - 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), - - 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), - 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), - 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), - 'hex tab' => array( '\\9', "\t", '' ), - 'hex a' => array( '\\61 bc', 'abc', '' ), - 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), - - 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), - 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), - 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), - 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), - 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), - 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), - 'can start with -ident' => array( '-ident', '-ident', '' ), - 'can start with --anything' => array( '--anything', '--anything', '' ), - 'can start with ---anything' => array( '--_anything', '--_anything', '' ), - 'can start with --1anything' => array( '--1anything', '--1anything', '' ), - 'can start with -\31 23' => array( '-\31 23', '-123', '' ), - 'can start with --\31 23' => array( '--\31 23', '--123', '' ), - 'ident ends before ]' => array( 'ident]', 'ident', ']' ), - - // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: bad start >' => array( '>ident' ), - 'Invalid: bad start [' => array( '[ident' ), - 'Invalid: bad start #' => array( '#ident' ), - 'Invalid: bad start " "' => array( ' ident' ), - 'Invalid: bad start 1' => array( '1ident' ), - 'Invalid: bad start -1' => array( '-1ident' ), - 'Invalid: bad start -' => array( '-' ), - ); - } - - /** - * @ticket 62653 - */ - public function test_is_ident_and_is_ident_start() { - $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); - $this->assertFalse( $this->test_class::test_is_ident_codepoint( ']', 0 ) ); - $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( '[', 0 ) ); - $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( ']', 0 ) ); - } - - /** - * @ticket 62653 - * - * @dataProvider data_idents - */ - public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { - - $offset = 0; - $result = $this->test_class::test_parse_ident( $input, $offset ); - if ( null === $expected ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected, $result, 'Ident did not match.' ); - $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); - } - } - - /** - * @ticket 62653 - * - * @dataProvider data_strings - */ - public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { - $offset = 0; - $result = $this->test_class::test_parse_string( $input, $offset ); - if ( null === $expected ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected, $result, 'String did not match.' ); - $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); - } - } - - /** - * Data provider. - * - * @return array - */ - public static function data_strings(): array { - return array( - '"foo"' => array( '"foo"', 'foo', '' ), - '"foo"after' => array( '"foo"after', 'foo', 'after' ), - '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), - '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), - - "'foo'" => array( "'foo'", 'foo', '' ), - "'foo'after" => array( "'foo'after", 'foo', 'after' ), - "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), - "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), - - "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), - "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), - "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), - "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), - "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), - - "'foo\\" => array( "'foo\\", 'foo', '' ), - - '"' => array( '"', '', '' ), - '"\\"' => array( '"\\"', '"', '' ), - '"missing close' => array( '"missing close', 'missing close', '' ), - - // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: .foo' => array( '.foo' ), - 'Invalid: #foo' => array( '#foo' ), - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), - ); - } - - /** - * @ticket 62653 - * - * @dataProvider data_id_selectors - */ - public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { - $offset = 0; - $result = $this->test_class::test_parse_id_selector( $input, $offset ); - if ( null === $expected ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected, $result->id ); - $this->assertSame( $rest, substr( $input, $offset ) ); - } - } - - /** - * Data provider. - * - * @return array - */ - public static function data_id_selectors(): array { - return array( - 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), - 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), - 'escaped #\31 23' => array( '#\\31 23', '123', '' ), - 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), - - 'not ID foo' => array( 'foo' ), - 'not ID .bar' => array( '.bar' ), - 'not valid #1foo' => array( '#1foo' ), - ); - } - - /** - * @ticket 62653 - * - * @dataProvider data_class_selectors - */ - public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { - $offset = 0; - $result = $this->test_class::test_parse_class_selector( $input, $offset ); - if ( null === $expected ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected, $result->class_name ); - $this->assertSame( $rest, substr( $input, $offset ) ); - } - } - - /** - * Data provider. - * - * @return array - */ - public static function data_class_selectors(): array { - return array( - 'valid ._-foo123' => array( '._-foo123', '_-foo123', '' ), - 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), - 'escaped .\31 23' => array( '.\\31 23', '123', '' ), - 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), - - 'not class foo' => array( 'foo' ), - 'not class #bar' => array( '#bar' ), - 'not valid .1foo' => array( '.1foo' ), - ); - } - - /** - * @ticket 62653 - * - * @dataProvider data_type_selectors - */ - public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { - $offset = 0; - $result = $this->test_class::test_parse_type_selector( $input, $offset ); - if ( null === $expected ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected, $result->type ); - $this->assertSame( $rest, substr( $input, $offset ) ); - } - } - - /** - * Data provider. - * - * @return array - */ - public static function data_type_selectors(): array { - return array( - 'any *' => array( '* .class', '*', ' .class' ), - 'a' => array( 'a', 'a', '' ), - 'div.class' => array( 'div.class', 'div', '.class' ), - 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), - - // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: #id' => array( '#id' ), - 'Invalid: .class' => array( '.class' ), - 'Invalid: [attr]' => array( '[attr]' ), - ); - } - - /** - * @ticket 62653 - * - * @dataProvider data_attribute_selectors - */ - public function test_parse_attribute( - string $input, - ?string $expected_name = null, - ?string $expected_matcher = null, - ?string $expected_value = null, - ?string $expected_modifier = null, - ?string $rest = null - ) { - $offset = 0; - $result = $this->test_class::test_parse_attribute_selector( $input, $offset ); - if ( null === $expected_name ) { - $this->assertNull( $result ); - } else { - $this->assertSame( $expected_name, $result->name ); - $this->assertSame( $expected_matcher, $result->matcher ); - $this->assertSame( $expected_value, $result->value ); - $this->assertSame( $expected_modifier, $result->modifier ); - $this->assertSame( $rest, substr( $input, $offset ) ); - } - } - - /** - * Data provider. - * - * @return array - */ - public static function data_attribute_selectors(): array { - return array( - '[href]' => array( '[href]', 'href', null, null, null, '' ), - '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), - '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), - '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), - '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), - '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), - '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), - - '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), - "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), - '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), - - // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), - 'Invalid: [#foo]' => array( '[#foo]' ), - 'Invalid: [*|*]' => array( '[*|*]' ), - 'Invalid: [ns|*]' => array( '[ns|*]' ), - 'Invalid: [* |att]' => array( '[* |att]' ), - 'Invalid: [*| att]' => array( '[*| att]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att+=val]' => array( '[att+=val]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), - 'Invalid: [att i]' => array( '[att i]' ), - 'Invalid: [att s]' => array( '[att s]' ), - "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), - 'Invalid: [att="val"ix' => array( '[att="val"ix' ), - ); - } - - /** - * @ticket 62653 - */ - public function test_parse_selector() { - $input = 'el.foo#bar[baz=quux] > .child'; - $offset = 0; - $sel = $this->test_class::test_parse_compound_selector( $input, $offset ); - - $this->assertSame( 'el', $sel->type_selector->type ); - $this->assertSame( 3, count( $sel->subclass_selectors ) ); - $this->assertSame( 'foo', $sel->subclass_selectors[0]->class_name, 'foo' ); - $this->assertSame( 'bar', $sel->subclass_selectors[1]->id, 'bar' ); - $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); - $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); - $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); - $this->assertSame( ' > .child', substr( $input, $offset ) ); - } - - /** - * @ticket 62653 - */ - public function test_parse_empty_selector() { - $input = ''; - $offset = 0; - $result = $this->test_class::test_parse_compound_selector( $input, $offset ); - $this->assertNull( $result ); - $this->assertSame( 0, $offset ); - } - /** * @ticket 62653 */ diff --git a/tests/phpunit/tests/html-api/wpCssIdSelector.php b/tests/phpunit/tests/html-api/wpCssIdSelector.php new file mode 100644 index 0000000000000..6cd6b83a46b8d --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssIdSelector.php @@ -0,0 +1,50 @@ +assertNull( $result ); + } else { + $this->assertSame( $expected, $result->id ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_id_selectors(): array { + return array( + 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), + 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), + 'escaped #\31 23' => array( '#\\31 23', '123', '' ), + 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), + + // Invalid + 'not ID foo' => array( 'foo' ), + 'not ID .bar' => array( '.bar' ), + 'not valid #1foo' => array( '#1foo' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php new file mode 100644 index 0000000000000..4497334791c88 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -0,0 +1,172 @@ +test_class = new class() extends WP_CSS_Selector_Parser_Matcher { + /* + * Parsing + */ + public static function test_parse_ident( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + + public static function test_parse_string( string $input, int &$offset ) { + return self::parse_string( $input, $offset ); + } + + /* + * Utilities + */ + public static function test_is_ident_codepoint( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start_codepoint( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + } + + /** + * Data provider. + * + * @return array + */ + public static function data_idents(): array { + return array( + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), + 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), + + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + + 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), + 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), + 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), + 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), + 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), + 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + 'can start with -ident' => array( '-ident', '-ident', '' ), + 'can start with --anything' => array( '--anything', '--anything', '' ), + 'can start with ---anything' => array( '--_anything', '--_anything', '' ), + 'can start with --1anything' => array( '--1anything', '--1anything', '' ), + 'can start with -\31 23' => array( '-\31 23', '-123', '' ), + 'can start with --\31 23' => array( '--\31 23', '--123', '' ), + 'ident ends before ]' => array( 'ident]', 'ident', ']' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: bad start >' => array( '>ident' ), + 'Invalid: bad start [' => array( '[ident' ), + 'Invalid: bad start #' => array( '#ident' ), + 'Invalid: bad start " "' => array( ' ident' ), + 'Invalid: bad start 1' => array( '1ident' ), + 'Invalid: bad start -1' => array( '-1ident' ), + 'Invalid: bad start -' => array( '-' ), + ); + } + + /** + * @ticket 62653 + */ + public function test_is_ident_and_is_ident_start() { + $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( ']', 0 ) ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_idents + */ + public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { + + $offset = 0; + $result = $this->test_class::test_parse_ident( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'Ident did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * @ticket 62653 + * + * @dataProvider data_strings + */ + public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = $this->test_class::test_parse_string( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'String did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_strings(): array { + return array( + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + "'foo\\" => array( "'foo\\", 'foo', '' ), + + '"' => array( '"', '', '' ), + '"\\"' => array( '"\\"', '"', '' ), + '"missing close' => array( '"missing close', 'missing close', '' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssTypeSelector.php b/tests/phpunit/tests/html-api/wpCssTypeSelector.php new file mode 100644 index 0000000000000..fb53c41dd058c --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssTypeSelector.php @@ -0,0 +1,51 @@ +assertNull( $result ); + } else { + $this->assertSame( $expected, $result->type ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_type_selectors(): array { + return array( + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: #id' => array( '#id' ), + 'Invalid: .class' => array( '.class' ), + 'Invalid: [attr]' => array( '[attr]' ), + ); + } +} From f217eb0de026fcee8beb645f941de5221c676795 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 18:45:20 +0100 Subject: [PATCH 118/336] Use whitepsace chars constant --- .../html-api/class-wp-css-attribute-selector.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 700a8cba9bb0c..ab566f8f1af11 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -231,15 +231,15 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { */ private function whitespace_delimited_list( string $input ): Generator { // Start by skipping whitespace. - $offset = strspn( $input, " \t\r\n\f" ); + $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. - $length = strcspn( $input, " \t\r\n\f", $offset ); + $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $value = substr( $input, $offset, $length ); // Move past trailing whitespace. - $offset += $length + strspn( $input, " \t\r\n\f", $offset + $length ); + $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; } From 6154742ecb42762951eb2267fe12434417f7bf85 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 18:46:14 +0100 Subject: [PATCH 119/336] parse_whitespace should be protected --- .../html-api/class-wp-css-selector-parser-matcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index 8820115f03cfb..744f75496f0f8 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -34,7 +34,7 @@ abstract public static function parse( string $input, int &$offset ): ?static; /** * @todo document */ - final public static function parse_whitespace( string $input, int &$offset ): bool { + final protected static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $advanced = $length > 0; $offset += $length; From 577b3a3b7036b9db71d6c0ab3337b96f94960686 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 18:48:01 +0100 Subject: [PATCH 120/336] Update interface to abstract class require --- src/wp-settings.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-settings.php b/src/wp-settings.php index b52fe8ab6181c..2e6ed6091a682 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -265,8 +265,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; -require ABSPATH . WPINC . '/html-api/interface-wp-css-html-tag-processor-matcher.php'; -require ABSPATH . WPINC . '/html-api/interface-wp-css-html-processor-matcher.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-selector-parser-matcher.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-attribute-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-class-selector.php'; require ABSPATH . WPINC . '/html-api/class-wp-css-id-selector.php'; From 5ea93abbf5704268239c3129a43e9bd9834e34b8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:04:03 +0100 Subject: [PATCH 121/336] Document base class --- .../class-wp-css-selector-parser-matcher.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index 744f75496f0f8..60e75820c264a 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -1,5 +1,19 @@ Date: Wed, 11 Dec 2024 19:06:19 +0100 Subject: [PATCH 122/336] Invert and comment confusing compound selector condition --- .../html-api/class-wp-css-compound-selector.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 68aca4d880e0d..f301f6f9342fd 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -95,12 +95,13 @@ public static function parse( string $input, int &$offset ): ?static { $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); } - // @todo invert this condition - if ( null !== $type_selector || array() !== $subclass_selectors ) { - $offset = $updated_offset; - return new self( $type_selector, $subclass_selectors ); + // There must be at least one selector. + if ( null === $type_selector && array() === $subclass_selectors ) { + return null; } - return null; + + $offset = $updated_offset; + return new self( $type_selector, $subclass_selectors ); } /** From db469e62def02391ab362d5b7fd01ee4f54606d0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:08:46 +0100 Subject: [PATCH 123/336] Use switch in compound selector parsing --- .../class-wp-css-compound-selector.php | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index f301f6f9342fd..002021472f496 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -116,16 +116,15 @@ private static function parse_subclass_selector( string $input, int &$offset ) { return null; } - $next_char = $input[ $offset ]; - return '.' === $next_char - ? WP_CSS_Class_Selector::parse( $input, $offset ) - : ( - '#' === $next_char - ? WP_CSS_ID_Selector::parse( $input, $offset ) - : ( '[' === $next_char - ? WP_CSS_Attribute_Selector::parse( $input, $offset ) - : null - ) - ); + switch ( $input[ $offset ] ) { + case '.': + return WP_CSS_Class_Selector::parse( $input, $offset ); + case '#': + return WP_CSS_ID_Selector::parse( $input, $offset ); + case '[': + return WP_CSS_Attribute_Selector::parse( $input, $offset ); + } + + return null; } } From 400263a007f5e5aa6a0394343bf3dd827e32e029 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:20:45 +0100 Subject: [PATCH 124/336] Fix up some todo-s --- .../class-wp-css-complex-selector-list.php | 8 -------- .../class-wp-css-compound-selector-list.php | 14 ++++++-------- .../class-wp-css-selector-parser-matcher.php | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 10af613174a35..940bd098c6c19 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -44,14 +44,6 @@ class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { * @return static|null The selector instance, or null if the parse was unsuccessful. */ public static function parse( string $input, int &$offset ): ?static { - $input = self::normalize_selector_input( $input ); - - if ( '' === $input ) { - return null; - } - - $offset = 0; - $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a6f3b87409ff6..7edafc779ac4c 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -122,6 +122,12 @@ protected function __construct( array $selectors ) { * @return static|null */ public static function from_selectors( string $input ): ?static { + $input = self::normalize_selector_input( $input ); + + if ( '' === $input ) { + return null; + } + $offset = 0; return static::parse( $input, $offset ); } @@ -137,14 +143,6 @@ public static function from_selectors( string $input ): ?static { * @return static|null The selector instance, or null if the parse was unsuccessful. */ public static function parse( string $input, int &$offset ): ?static { - $input = self::normalize_selector_input( $input ); - - if ( '' === $input ) { - return null; - } - - $offset = 0; - $selector = WP_CSS_Compound_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index 60e75820c264a..6d665c4c26cb0 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -46,7 +46,12 @@ abstract public static function parse( string $input, int &$offset ): ?static; */ /** - * @todo document + * Consumes whitespace from the input string. + * + * @param string $input The selector string. + * @param int $offset The offset into the string. The offset is passed by reference and will + * be update to the byte after the whitespace sequence. + * @return bool True if whitespace was consumed. */ final protected static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); @@ -289,7 +294,6 @@ final protected static function parse_ident( string $input, int &$offset ): ?str $ident .= self::consume_escaped_codepoint( $input, $offset ); continue; } elseif ( self::is_ident_codepoint( $input, $offset ) ) { - // @todo this should append and advance the correct number of bytes. $ident .= $input[ $offset ]; ++$offset; continue; @@ -338,7 +342,7 @@ final protected static function next_two_are_valid_escape( string $input, int $o } /** - * Checks if the next code point is an "ident start code point". + * Checks if the next code point is an "ident start code point." * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -370,7 +374,7 @@ final protected static function is_ident_start_codepoint( string $input, int $of } /** - * Checks if the next code point is an "ident code point". + * Checks if the next code point is an "ident code point." * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -461,7 +465,12 @@ final protected static function check_if_three_code_points_would_start_an_ident_ } /** - * @todo doc… + * Normalizes selector input for processing. + * + * @see https://www.w3.org/TR/css-syntax-3/#input-preprocessing + * + * @param string $input The selector string. + * @return string The normalized selector string. */ final protected static function normalize_selector_input( string $input ): string { /* From 483a8191401c91f53601033ba3977a068bd03446 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:24:22 +0100 Subject: [PATCH 125/336] Make most selector constructors private --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index ab566f8f1af11..d2d4d17792a81 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -140,7 +140,7 @@ final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { * @param string|null $modifier The attribute case modifier. * Must be one of the class MODIFIER_* constants or null. */ - public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; $this->matcher = $matcher; $this->value = $value; diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index 9abcb881ace49..ff7a0b0442813 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -29,7 +29,7 @@ final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser_Matcher { * * @param string $class_name The class name to match. */ - public function __construct( string $class_name ) { + private function __construct( string $class_name ) { $this->class_name = $class_name; } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 002021472f496..077ed5aa4b7f3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -45,7 +45,7 @@ final class WP_CSS_Compound_Selector extends WP_CSS_Selector_Parser_Matcher { * @param (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null $subclass_selectors * The array of subclass selectors or null. */ - public function __construct( ?WP_CSS_Type_Selector $type_selector, ?array $subclass_selectors ) { + private function __construct( ?WP_CSS_Type_Selector $type_selector, ?array $subclass_selectors ) { $this->type_selector = $type_selector; $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; } diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index de854c37eea9f..2c7cb6feec658 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -29,7 +29,7 @@ final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser_Matcher { * * @param string $id The ID to match. */ - public function __construct( string $id ) { + private function __construct( string $id ) { $this->id = $id; } diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index 492569ee51d65..ab41a87f1a113 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -29,7 +29,7 @@ final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser_Matcher { * * @param string $type The element type (tag name) to match or '*' to match any element. */ - public function __construct( string $type ) { + private function __construct( string $type ) { $this->type = $type; } From 1f641685627d8536887c69d25e5f69466cb1f076 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:32:17 +0100 Subject: [PATCH 126/336] Fix test class implementation of abstract class --- .../phpunit/tests/html-api/wpCssSelectorParserMatcher.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 4497334791c88..4e0dd23af12f7 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -16,6 +16,13 @@ class Tests_HtmlApi_WpCssSelectorParserMatcher extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); $this->test_class = new class() extends WP_CSS_Selector_Parser_Matcher { + public function matches( $processor ): bool { + throw new Exeption( 'Matches called on test class.' ); + } + public static function parse( string $input, int &$offset ): ?static { + throw new Exeption( 'Parse called on test class.' ); + } + /* * Parsing */ From 3bfb8a13acbfa5a5360d4555bdc6102f4236d8e5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:41:30 +0100 Subject: [PATCH 127/336] Remove php 8+ ?static return types --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 4 ++-- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- .../html-api/class-wp-css-selector-parser-matcher.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index d2d4d17792a81..dc3c13a5ea534 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -255,7 +255,7 @@ private function whitespace_delimited_list( string $input ): Generator { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { // Need at least 3 bytes [x] if ( $offset + 2 >= strlen( $input ) ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index ff7a0b0442813..57f7dac50315f 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -53,7 +53,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { return null; } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 940bd098c6c19..d819cd469086f 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -43,7 +43,7 @@ class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 7c997c62a80f7..8c7c25ed7b984 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -200,7 +200,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { if ( $offset >= strlen( $input ) ) { return null; } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 7edafc779ac4c..41cf76e2c90f6 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -121,7 +121,7 @@ protected function __construct( array $selectors ) { * @param string $input CSS selectors. * @return static|null */ - public static function from_selectors( string $input ): ?static { + public static function from_selectors( string $input ) { $input = self::normalize_selector_input( $input ); if ( '' === $input ) { @@ -142,7 +142,7 @@ public static function from_selectors( string $input ): ?static { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { $selector = WP_CSS_Compound_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 077ed5aa4b7f3..91e543fdc7e7e 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -80,7 +80,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { if ( $offset >= strlen( $input ) ) { return null; } diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 2c7cb6feec658..f0c203dc6477e 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -62,7 +62,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { $ident = self::parse_hash_token( $input, $offset ); if ( null === $ident ) { return null; diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index 6d665c4c26cb0..e2b56a7b9e55c 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -34,7 +34,7 @@ abstract public function matches( WP_HTML_Tag_Processor $processor ): bool; * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - abstract public static function parse( string $input, int &$offset ): ?static; + abstract public static function parse( string $input, int &$offset ); /* * ------------------------ diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index ab41a87f1a113..c16883fa60679 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -70,7 +70,7 @@ public function matches_tag( string $tag_name ): bool { * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { if ( $offset >= strlen( $input ) ) { return null; } From 8d2aef2f19c99a7e1401fc29d16e56a930bad948 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:47:59 +0100 Subject: [PATCH 128/336] Fix typo in Exception class name --- tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 4e0dd23af12f7..bf84f30637510 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -17,10 +17,10 @@ public function set_up(): void { parent::set_up(); $this->test_class = new class() extends WP_CSS_Selector_Parser_Matcher { public function matches( $processor ): bool { - throw new Exeption( 'Matches called on test class.' ); + throw new Error( 'Matches called on test class.' ); } public static function parse( string $input, int &$offset ): ?static { - throw new Exeption( 'Parse called on test class.' ); + throw new Error( 'Parse called on test class.' ); } /* From 33b83338c827a67c48328dbf8f4c2dd70893ba9c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 11 Dec 2024 19:48:37 +0100 Subject: [PATCH 129/336] Remove ?static return type from test --- tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index bf84f30637510..29a76bfd78723 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -19,7 +19,7 @@ public function set_up(): void { public function matches( $processor ): bool { throw new Error( 'Matches called on test class.' ); } - public static function parse( string $input, int &$offset ): ?static { + public static function parse( string $input, int &$offset ) { throw new Error( 'Parse called on test class.' ); } From 016d897f35d7b59400ba4b2fd386065b187e8e4d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Jul 2025 19:36:44 +0200 Subject: [PATCH 130/336] Change MATCH_EXACT_OR_HYPHEN_PREFIXED to _SUFFIXED This is a more appropriate name for the type of match. > `[att|=val]` Represents an element with the att attribute, its value > either being exactly "val" or beginning with "val" immediately > followed by "-" (U+002D). This is primarily intended to allow language > subcode matches (e.g., the hreflang attribute on the a element in > HTML) as described in BCP 47 ([BCP47]) or its successor. --- .../html-api/class-wp-css-attribute-selector.php | 8 ++++---- tests/phpunit/tests/html-api/wpCssAttributeSelector.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index dc3c13a5ea534..e104b05fabf8c 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -43,7 +43,7 @@ final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { * * [attr|=value] */ - const MATCH_EXACT_OR_HYPHEN_PREFIXED = 'exact-or-hyphen-prefixed'; + const MATCH_EXACT_OR_HYPHEN_SUFFIXED = 'exact-or-hyphen-suffixed'; /** * The attribute value matches the start of the attribute. @@ -103,7 +103,7 @@ final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { * Allowed string values are the class constants: * - {@see WP_CSS_Attribute_Selector::MATCH_EXACT} * - {@see WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT} - * - {@see WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED} + * - {@see WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_SUFFIXED} * - {@see WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY} * - {@see WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY} * - {@see WP_CSS_Attribute_Selector::MATCH_CONTAINS} @@ -187,7 +187,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { } return false; - case self::MATCH_EXACT_OR_HYPHEN_PREFIXED: + case self::MATCH_EXACT_OR_HYPHEN_SUFFIXED: // Attempt the full match first if ( $case_insensitive @@ -299,7 +299,7 @@ public static function parse( string $input, int &$offset ) { $updated_offset += 2; break; case '|': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_SUFFIXED; $updated_offset += 2; break; case '^': diff --git a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php index d907ad7c07e5b..45fa787f7a4ff 100644 --- a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php @@ -57,7 +57,7 @@ public static function data_attribute_selectors(): array { '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_PREFIXED, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_SUFFIXED, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), From c0fa8d54a4a396ddf25b12aeb51462302b33e49c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Jul 2025 19:43:54 +0200 Subject: [PATCH 131/336] Use "attr" instead of "att" as short "attributes" --- .../class-wp-css-attribute-selector.php | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index e104b05fabf8c..7d9acd1665b51 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -22,7 +22,7 @@ final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { * * @example * - * [att=val] + * [attr=val] */ const MATCH_EXACT = 'exact'; @@ -154,8 +154,8 @@ private function __construct( string $name, ?string $matcher = null, ?string $va * @return bool True if the processor's current position matches the selector. */ public function matches( WP_HTML_Tag_Processor $processor ): bool { - $att_value = $processor->get_attribute( $this->name ); - if ( null === $att_value ) { + $attr_value = $processor->get_attribute( $this->name ); + if ( null === $attr_value ) { return false; } @@ -163,8 +163,8 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { return true; } - if ( true === $att_value ) { - $att_value = ''; + if ( true === $attr_value ) { + $attr_value = ''; } $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; @@ -172,11 +172,11 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { switch ( $this->matcher ) { case self::MATCH_EXACT: return $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value; + ? 0 === strcasecmp( $attr_value, $this->value ) + : $attr_value === $this->value; case self::MATCH_ONE_OF_EXACT: - foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + foreach ( $this->whitespace_delimited_list( $attr_value ) as $val ) { if ( $case_insensitive ? 0 === strcasecmp( $val, $this->value ) @@ -191,31 +191,31 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { // Attempt the full match first if ( $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value + ? 0 === strcasecmp( $attr_value, $this->value ) + : $attr_value === $this->value ) { return true; } // Partial match - if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + if ( strlen( $attr_value ) < strlen( $this->value ) + 1 ) { return false; } $starts_with = "{$this->value}-"; - return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + return 0 === substr_compare( $attr_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); case self::MATCH_PREFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + return 0 === substr_compare( $attr_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); case self::MATCH_SUFFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + return 0 === substr_compare( $attr_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); case self::MATCH_CONTAINS: return false !== ( $case_insensitive - ? stripos( $att_value, $this->value ) - : strpos( $att_value, $this->value ) + ? stripos( $attr_value, $this->value ) + : strpos( $attr_value, $this->value ) ); } } From cfa2bc2ecb17d2893cb239db168608f295ee187f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Jul 2025 20:13:46 +0200 Subject: [PATCH 132/336] Simplify exact or hyphen suffixed implementation --- .../class-wp-css-attribute-selector.php | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 7d9acd1665b51..dde5f12bbf962 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -188,22 +188,12 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { return false; case self::MATCH_EXACT_OR_HYPHEN_SUFFIXED: - // Attempt the full match first - if ( - $case_insensitive - ? 0 === strcasecmp( $attr_value, $this->value ) - : $attr_value === $this->value - ) { - return true; - } - - // Partial match - if ( strlen( $attr_value ) < strlen( $this->value ) + 1 ) { - return false; - } - - $starts_with = "{$this->value}-"; - return 0 === substr_compare( $attr_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + $exact_length = strlen( $this->value ); + $matches_prefix = substr_compare( $attr_value, $this->value, 0, $exact_length, $case_insensitive ); + return ( + 0 === $matches_prefix && + ( strlen( $attr_value ) === $exact_length || '-' === $attr_value[ $exact_length ] ) + ); case self::MATCH_PREFIXED_BY: return 0 === substr_compare( $attr_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); From dc789e41f26f9b08ab9f306ffc51be7e3945daec Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 14 Jul 2025 12:44:26 -0500 Subject: [PATCH 133/336] Only expose `select()` for a `while ( $->select() )` loop Matches the calling interface for the other HTML API classes, avoids creating the `Generator`, using a static var to avoid re-parsing the selector string instead. --- .../class-wp-css-complex-selector-list.php | 2 +- .../class-wp-css-compound-selector-list.php | 2 +- .../html-api/class-wp-html-processor.php | 52 ++++++------------- .../html-api/class-wp-html-tag-processor.php | 52 ++++++------------- .../tests/html-api/wpHtmlProcessor-select.php | 9 ++-- .../html-api/wpHtmlTagProcessor-select.php | 8 +-- 6 files changed, 41 insertions(+), 84 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index d819cd469086f..5d6c4029af08e 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -12,7 +12,7 @@ * * This class is designed for internal use by the HTML processor. * - * For usage, see {@see WP_HTML_Processor::select()} or {@see WP_HTML_Processor::select_all()}. + * For usage, see {@see WP_HTML_Processor::select()}. * * This class is instantiated via the {@see WP_CSS_Complex_Selector_List::from_selectors()} method. * It takes a CSS selector string and returns an instance of itself or `null` if the selector diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 41cf76e2c90f6..d12f6cdeda944 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -12,7 +12,7 @@ * * This class is designed for internal use by the HTML Tag Processor. * - * For usage, see {@see WP_HTML_Tag_Processor::select()} or {@see WP_HTML_Tag_Processor::select_all()}. + * For usage, see {@see WP_HTML_Tag_Processor::select()}. * * This class is instantiated via the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. * It takes a CSS selector string and returns an instance of itself or `null` if the selector diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 4be8b2860f3ce..1f23e93a4023d 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -640,12 +640,12 @@ public function get_unsupported_exception() { /** * Progress through a document pausing on tags matching the provided CSS selector string. * - * @example + * Example: * * $processor = WP_HTML_Processor::create_fragment( * 'Example' * ); - * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * while ( $processor->select( 'meta[property^="og:" i]' ) ) { * // Loop is entered twice. * var_dump( * $processor->get_tag(), // string(4) "META" @@ -654,55 +654,37 @@ public function get_unsupported_exception() { * ); * } * - * @since 6.8.0 + * @since {WP_VERSION} * * @param string $selector_string Selector string. - * @return Generator A generator pausing on each tag matching the selector. + * @return bool Whether a selection was found. */ - public function select_all( $selector_string ): Generator { - $selector = WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + public function select( $selector_string ): bool { + static $previous_selector_string = null; + static $previous_selector = null; + + $selector = $selector_string === $previous_selector_string + ? $previous_selector + : WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + + $previous_selector = $selector; + $previous_selector_string = $selector_string; + if ( null === $selector ) { _doing_it_wrong( __METHOD__, sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), '6.8' ); - return; + return false; } while ( $this->next_tag() ) { if ( $selector->matches( $this ) ) { - yield; + return true; } } - } - /** - * Move to the next tag matching the provided CSS selector string. - * - * This method will stop at the next match. To progress through all matches, use - * the {@see WP_HTML_Processor::select_all()} method. - * - * @example - * - * $processor = WP_HTML_Processor::create_fragment( - * 'Example' - * ); - * $processor->select( 'meta[charset]' ); - * var_dump( - * $processor->get_tag(), // string(4) "META" - * $processor->get_attribute( 'charset' ), // string(5) "utf-8" - * ); - * - * @since 6.8.0 - * - * @param string $selector_string - * @return bool True if a matching tag was found, otherwise false. - */ - public function select( string $selector_string ): bool { - foreach ( $this->select_all( $selector_string ) as $_ ) { - return true; - } return false; } 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 0f6b59441c75b..2f9d27e0f415c 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 @@ -863,12 +863,12 @@ public function change_parsing_namespace( string $new_namespace ): bool { /** * Progress through a document pausing on tags matching the provided CSS selector string. * - * @example + * Example: * * $processor = new WP_HTML_Tag_Processor( * 'Example' * ); - * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * while ( $processor->select( 'meta[property^="og:" i]' ) ) { * // Loop is entered twice. * var_dump( * $processor->get_tag(), // string(4) "META" @@ -877,55 +877,37 @@ public function change_parsing_namespace( string $new_namespace ): bool { * ); * } * - * @since 6.8.0 + * @since {WP_VERSION} * * @param string $selector_string Selector string. - * @return Generator A generator pausing on each tag matching the selector. + * @return bool Whether a selection was found. */ - public function select_all( $selector_string ): Generator { - $selector = WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + public function select( $selector_string ): bool { + static $previous_selector_string = null; + static $previous_selector = null; + + $selector = $selector_string === $previous_selector_string + ? $previous_selector + : WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + + $previous_selector = $selector; + $previous_selector_string = $selector_string; + if ( null === $selector ) { _doing_it_wrong( __METHOD__, sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), '6.8' ); - return; + return false; } while ( $this->next_tag() ) { if ( $selector->matches( $this ) ) { - yield; + return true; } } - } - /** - * Move to the next tag matching the provided CSS selector string. - * - * This method will stop at the next match. To progress through all matches, use - * the {@see WP_HTML_Tag_Processor::select_all()} method. - * - * @example - * - * $processor = new WP_HTML_Tag_Processor( - * 'Example' - * ); - * $processor->select( 'meta[charset]' ); - * var_dump( - * $processor->get_tag(), // string(4) "META" - * $processor->get_attribute( 'charset' ), // string(5) "utf-8" - * ); - * - * @since 6.8.0 - * - * @param string $selector_string - * @return bool True if a matching tag was found, otherwise false. - */ - public function select( string $selector_string ): bool { - foreach ( $this->select_all( $selector_string ) as $_ ) { - return true; - } return false; } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index a8f6a7c949080..6ce8e6606fc51 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -3,10 +3,7 @@ * Unit tests covering WP_HTML_Processor select functionality. * * Covers functionality related to CSS selectors and the {@see WP_HTML_Processor::select()} - * and {@see WP_HTML_Processor::select_all()} methods. - * - * @package WordPress - * @subpackage HTML-API + * and {@see WP_HTML_Processor::select()} methods. * * @since 6.8.0 * @@ -26,10 +23,10 @@ public function test_select_miss() { * * @dataProvider data_selectors */ - public function test_select_all( string $html, string $selector, int $match_count ) { + public function test_selects_all_matches( string $html, string $selector, int $match_count ) { $processor = WP_HTML_Processor::create_full_parser( $html ); $count = 0; - foreach ( $processor->select_all( $selector ) as $_ ) { + while ( $processor->select( $selector ) ) { $breadcrumb_string = implode( ', ', $processor->get_breadcrumbs() ); $this->assertTrue( $processor->get_attribute( 'match' ), diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 28f88778629ce..4f35fc777f3b6 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -2,11 +2,7 @@ /** * Unit tests covering WP_HTML_Tag_Processor CSS selection functionality. * - * Covers functionality related to CSS selectors and the {@see WP_HTML_Tag_Processor::select()} - * and {@see WP_HTML_Tag_Processor::select_all()} methods. - * - * @package WordPress - * @subpackage HTML-API + * Covers functionality related to CSS selectors and the {@see WP_HTML_Tag_Processor::select()} method. * * @since 6.8.0 * @@ -29,7 +25,7 @@ public function test_select_miss() { public function test_select( string $html, string $selector, int $match_count ) { $processor = new WP_HTML_Tag_Processor( $html ); $count = 0; - foreach ( $processor->select_all( $selector ) as $_ ) { + while ( $processor->select( $selector ) ) { $this->assertTrue( $processor->get_attribute( 'match' ), "Matched unexpected tag {$processor->get_tag()}" From 2d931ef7e768ed6a522fbf44eb78b050de88c04d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:26:52 +0200 Subject: [PATCH 134/336] Fix expectedIncorrectUsage method name --- tests/phpunit/tests/html-api/wpHtmlProcessor-select.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 6ce8e6606fc51..9bdae5802dfe4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -59,7 +59,7 @@ public static function data_selectors(): array { /** * @ticket 62653 * - * @expectedIncorrectUsage WP_HTML_Processor::select_all + * @expectedIncorrectUsage WP_HTML_Processor::select * * @dataProvider data_invalid_selectors */ diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 4f35fc777f3b6..a133ea63bc3fa 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -83,7 +83,7 @@ public static function data_selectors(): array { /** * @ticket 62653 * - * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all + * @expectedIncorrectUsage WP_HTML_Tag_Processor::select * * @dataProvider data_invalid_selectors */ From 53ee08d14888d4dd9c5f4614e4721292c9803271 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:27:15 +0200 Subject: [PATCH 135/336] Improve and fix complex selector list documentation --- .../class-wp-css-complex-selector-list.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 5d6c4029af08e..bc1dd5f25d849 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -21,14 +21,24 @@ * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. * - * This class is rougly analogous to the in the grammar. See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. + * This class is rougly analogous to the in the grammar. + * See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. * - * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as: - * - The following combinators: - * - Next sibling (`el + el`) - * - Subsequent sibling (`el ~ el`) + * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as + * the following combinators: + * - Descendant (`ancestor descendant`) + * - Child (`parent > child`) * - * @since 6.8.0 + * Combinators may only be used with type selectors in the non-final position, for example: + * - `div [type=input]` is valid because the `div` type selector appears in a non-final position. + * - `[disabled] option` is NOT valid, because the `[disabled]` attribute selector appears + * a non-final position. + * + * These combinators are not supported: + * - Next sibling (`former-sibling + next-sibling`) + * - Subsequent sibling (`former-sibling ~ subsequent-sibling`) + * + * @since {WP_VERSION} * * @access private */ From a627471539c46d33b343fdc6155946e3f455aed5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:27:32 +0200 Subject: [PATCH 136/336] Add unsupports sibling selector tests --- .../phpunit/tests/html-api/wpHtmlProcessor-select.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 9bdae5802dfe4..419d9831cbe63 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -75,11 +75,15 @@ public function test_invalid_selector( string $selector ) { */ public static function data_invalid_selectors(): array { return array( - 'invalid selector' => array( '[invalid!selector]' ), + 'invalid selector' => array( '[invalid!selector]' ), // The class selectors below are not allowed in non-final position. - 'unsupported child selector' => array( '.parent > .child' ), - 'unsupported descendant selector' => array( '.ancestor .descendant' ), + 'unsupported child selector' => array( '.parent > .child' ), + 'unsupported descendant selector' => array( '.ancestor .descendant' ), + + // Unsupported combinators + 'unsupported next sibling selector' => array( 'p + p' ), + 'unsupported subsequent sibling selector' => array( 'p ~ p' ), ); } } From 0058e15e37c32a190d6a33132e8f5320bd94ea87 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:31:31 +0200 Subject: [PATCH 137/336] Do not support + and ~ selectors --- src/wp-includes/html-api/class-wp-css-complex-selector.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 8c7c25ed7b984..832bea2df6926 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -218,10 +218,14 @@ public static function parse( string $input, int &$offset ) { $combinator = null; $next_selector = null; + // Sibling (`+` and `~`) combinators are not supported at this time. if ( - WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + return null; + } elseif ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] ) { $combinator = $input[ $updated_offset ]; ++$updated_offset; From a99207dad15b164ed82940049dcb0c6e3f7162eb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:36:00 +0200 Subject: [PATCH 138/336] Update @since tags to WP_VERSION placeholder --- .../html-api/class-wp-css-attribute-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-class-selector.php | 4 ++-- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 6 +++--- .../html-api/class-wp-css-compound-selector-list.php | 6 ++---- src/wp-includes/html-api/class-wp-css-compound-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-id-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-css-type-selector.php | 4 ++-- src/wp-includes/html-api/class-wp-html-processor.php | 2 +- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 +- tests/phpunit/tests/html-api/wpCssAttributeSelector.php | 2 +- tests/phpunit/tests/html-api/wpCssClassSelector.php | 2 +- tests/phpunit/tests/html-api/wpCssComplexSelector.php | 2 +- tests/phpunit/tests/html-api/wpCssComplexSelectorList.php | 2 +- tests/phpunit/tests/html-api/wpCssCompoundSelector.php | 2 +- tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php | 2 +- tests/phpunit/tests/html-api/wpCssIdSelector.php | 2 +- tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php | 2 +- tests/phpunit/tests/html-api/wpCssTypeSelector.php | 2 +- tests/phpunit/tests/html-api/wpHtmlProcessor-select.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php | 2 +- 21 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index dde5f12bbf962..7e8e21bacbc26 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index 57f7dac50315f..121b3abf10f96 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index bc1dd5f25d849..80c84c10cfad9 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 832bea2df6926..96a4fd10be481 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -15,7 +15,7 @@ * A compound selector is at least a single compound selector. There may be additional selectors * with combinators. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ @@ -184,7 +184,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { __( 'Unsupported combinator "%s" found.' ), $combinator ), - '6.8.0' + '{WP_VERSION}' ); return false; } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index d12f6cdeda944..c55eccb0dfe56 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -67,7 +67,7 @@ * - `svg|*` to select all SVG elements * - `html|title` to select only HTML TITLE elements. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private * @@ -116,8 +116,6 @@ protected function __construct( array $selectors ) { * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. * - * @since 6.8.0 - * * @param string $input CSS selectors. * @return static|null */ diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index 91e543fdc7e7e..48e206819c0d3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -17,7 +17,7 @@ * - Zero or more subclass selectors (ID, class, or attribute selectors). * - At least one of the above. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index f0c203dc6477e..1d3b7f1f85d16 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index c16883fa60679..c7c7baa2d5508 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since 6.8.0 + * @since {WP_VERSION} */ /** @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Tag_Processor}. * - * @since 6.8.0 + * @since {WP_VERSION} * * @access private */ diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 1f23e93a4023d..a17ba18de3340 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -674,7 +674,7 @@ public function select( $selector_string ): bool { _doing_it_wrong( __METHOD__, sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), - '6.8' + '{WP_VERSION}' ); return false; } 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 2f9d27e0f415c..ac066a908b6c9 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 @@ -897,7 +897,7 @@ public function select( $selector_string ): bool { _doing_it_wrong( __METHOD__, sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), - '6.8' + '{WP_VERSION}' ); return false; } diff --git a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php index 45fa787f7a4ff..e574cedd1876b 100644 --- a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssClassSelector.php b/tests/phpunit/tests/html-api/wpCssClassSelector.php index fa1d097a5ad3d..9646d05da23d5 100644 --- a/tests/phpunit/tests/html-api/wpCssClassSelector.php +++ b/tests/phpunit/tests/html-api/wpCssClassSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelector.php b/tests/phpunit/tests/html-api/wpCssComplexSelector.php index bb7b6e67e9d1a..8738bb6fc32d2 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelector.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 4e788860ff53f..edf912e97f490 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelector.php b/tests/phpunit/tests/html-api/wpCssCompoundSelector.php index 8800c89d6ed36..8092ee049b6e1 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelector.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 01eff118a87b0..af05332c9aa3e 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssIdSelector.php b/tests/phpunit/tests/html-api/wpCssIdSelector.php index 6cd6b83a46b8d..6dc2e5461ea03 100644 --- a/tests/phpunit/tests/html-api/wpCssIdSelector.php +++ b/tests/phpunit/tests/html-api/wpCssIdSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 29a76bfd78723..29372172da2b1 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api */ diff --git a/tests/phpunit/tests/html-api/wpCssTypeSelector.php b/tests/phpunit/tests/html-api/wpCssTypeSelector.php index fb53c41dd058c..23d5f5517453a 100644 --- a/tests/phpunit/tests/html-api/wpCssTypeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssTypeSelector.php @@ -6,7 +6,7 @@ * * @subpackage HTML-API * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api * diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 419d9831cbe63..003e65e69ebce 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -5,7 +5,7 @@ * Covers functionality related to CSS selectors and the {@see WP_HTML_Processor::select()} * and {@see WP_HTML_Processor::select()} methods. * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api */ diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index a133ea63bc3fa..1d09c61b4760d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -4,7 +4,7 @@ * * Covers functionality related to CSS selectors and the {@see WP_HTML_Tag_Processor::select()} method. * - * @since 6.8.0 + * @since {WP_VERSION} * * @group html-api */ From 0fb0c2010f4e6c729fb410a4df7f8f8e331223ef Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:48:34 +0200 Subject: [PATCH 139/336] Add unsupported complex selector test --- .../phpunit/tests/html-api/wpCssCompoundSelectorList.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index af05332c9aa3e..8f1d3dfb88a45 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -48,4 +48,13 @@ public function test_parse_empty_selector_list() { $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } + + /** + * @ticket 62653 + */ + public function test_unsupported_complex_selector() { + $input = 'ancestor descendant'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } } From 2f47c32955139dae5fef66e21efa1ad8668ff1ed Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 10:53:10 +0200 Subject: [PATCH 140/336] Fix spelling and grammar in documentation --- .../html-api/class-wp-css-complex-selector-list.php | 4 ++-- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 2 +- .../html-api/class-wp-css-selector-parser-matcher.php | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 80c84c10cfad9..da5e17011e0d8 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -21,7 +21,7 @@ * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. * - * This class is rougly analogous to the in the grammar. + * This class is roughly analogous to the in the grammar. * See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. * * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as @@ -32,7 +32,7 @@ * Combinators may only be used with type selectors in the non-final position, for example: * - `div [type=input]` is valid because the `div` type selector appears in a non-final position. * - `[disabled] option` is NOT valid, because the `[disabled]` attribute selector appears - * a non-final position. + * in a non-final position. * * These combinators are not supported: * - Next sibling (`former-sibling + next-sibling`) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 96a4fd10be481..36671067be537 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -12,7 +12,7 @@ * * This class is used to test for matching HTML tags in a {@see WP_HTML_Processor}. * - * A compound selector is at least a single compound selector. There may be additional selectors + * A complex selector is at least a single compound selector. There may be additional selectors * with combinators. * * @since {WP_VERSION} diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index c55eccb0dfe56..d70cff59f7428 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -59,7 +59,7 @@ * - Pseudo-element selectors (`::before`) * - Pseudo-class selectors (`:hover` or `:nth-child(2)`) * - Namespace prefixes (`svg|title` or `[xlink|href]`) - * - No combinators are supported (descendant, child, next sibling, subsequent sibling) + * - Combinators are not supported (descendant, child, next sibling, subsequent sibling) * * Future ideas: * - Namespace type selectors could be implemented with select namespaces in order to diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index e2b56a7b9e55c..180ecba98bacf 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -8,7 +8,7 @@ */ /** - * Base class for all CSS Selector praser/matcher classes. + * Base class for all CSS Selector parser/matcher classes. * * @since 6.8.0 * @@ -50,7 +50,7 @@ abstract public static function parse( string $input, int &$offset ); * * @param string $input The selector string. * @param int $offset The offset into the string. The offset is passed by reference and will - * be update to the byte after the whitespace sequence. + * be updated to the byte after the whitespace sequence. * @return bool True if whitespace was consumed. */ final protected static function parse_whitespace( string $input, int &$offset ): bool { @@ -258,7 +258,7 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s /** * Parse an ident token * - * CAUTION: This method is _not_ for parsing and ID selector! + * CAUTION: This method is _not_ for parsing an ID selector! * * > 4.3.11. Consume an ident sequence * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. From c5723916f34c10fdf5e81ee636edddcc0a2e4f90 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 17:05:28 +0200 Subject: [PATCH 141/336] Improve documentation --- .../class-wp-css-complex-selector.php | 40 +++++++------------ .../class-wp-css-compound-selector-list.php | 5 ++- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 36671067be537..4ebe9fd476f5a 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -48,7 +48,7 @@ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser_Matcher { * The "self selector" is the last element in a complex selector, it corresponds to the * selected element. * - * @example + * Example: * * $self_selector * ┏━━━━┻━━━━┓ @@ -67,38 +67,28 @@ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser_Matcher { * the element at index 1 is the combinator string constant from this class, * e.g. `WP_CSS_Complex_Selector::COMBINATOR_CHILD`. * - * In the example selector below, an element like `` is selected iff: + * In the example selector below, an element like `` matches iff: * - it is a child of an `H1` element - * - *and* that `H1` element is a descendant of a `HEADING` element. + * - that `H1` element is a descendant of a `SECTION` element. * - * The `H1` and `HEADING` parts of this selector are the "context selectors." Note that this - * terminology is used for purposes of this class but does not correspond to language in the - * CSS or selector specifications. - * - * @example + * The `section` and `h1` parts of this selector and their combinators are the + * "context selectors." Note that this terminology does not correspond to language in the + * specification texts. * * $context_selectors - * ┏━━━━━━┻━━━━┓ - * .heading h1 > el.selected - * - * The example would have the following relative selectors: + * ┏━━━━━┻━━━━┓ + * section h1 > strong.selected * - * @example + * The example would have the following context selectors: * - * array ( - * array( - * WP_CSS_Type_Selector( 'ident' => 'h1' ), - * '>', // WP_CSS_Complex_Selector::COMBINATOR_CHILD - * ), - * array( - * new WP_CSS_Type_Selector( 'header' ), - * ' ', // WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT - * ), + * // Pseudo-code + * array( + * array( WP_CSS_Type_Selector( 'type'=>'h1' ), '>' ), + * array( WP_CSS_Type_Selector( 'type'=>'section' ), ' ' ), * ) * - * Note that the order of context selectors is reversed. This is to match the self selector - * first and then match the context selectors beginning with the selector closest to the self - * selector. + * Context selectors are ordered from right to left in the selector text. The selectors closest + * to the target appear at the start of the `context_selectors` array. * * @readonly * @var array{WP_CSS_Type_Selector, string}[]|null diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index d70cff59f7428..edc6a841a859a 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -41,7 +41,7 @@ * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * * This class of selectors does not support "complex" selectors. That is any selector with a - * combinator such as descendent (`.ancestor .descendant`) or child (`.parent > .child`). + * combinator such as descendant (`.ancestor .descendant`) or child (`.parent > .child`). * See {@see WP_CSS_Complex_Selector_List} for support of some combinators. * * Note that this grammar has been adapted and does not support the full CSS selector grammar. @@ -59,7 +59,8 @@ * - Pseudo-element selectors (`::before`) * - Pseudo-class selectors (`:hover` or `:nth-child(2)`) * - Namespace prefixes (`svg|title` or `[xlink|href]`) - * - Combinators are not supported (descendant, child, next sibling, subsequent sibling) + * - Combinators are not supported by this class (descendant, child, next sibling, + * subsequent sibling). See {@see WP_CSS_Complex_Selector_List} for combinator support. * * Future ideas: * - Namespace type selectors could be implemented with select namespaces in order to From ac2fb562c560033708cd8cec1321589c8af999f3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 17:18:47 +0200 Subject: [PATCH 142/336] More documentation improvements --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 4 ++-- .../html-api/class-wp-css-compound-selector-list.php | 4 +--- .../html-api/class-wp-css-selector-parser-matcher.php | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 7e8e21bacbc26..a63dfaba66b61 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -217,7 +217,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * * @param string $input * - * @return Generator + * @return Generator Yields each whitespace-delimited value from the input string. */ private function whitespace_delimited_list( string $input ): Generator { // Start by skipping whitespace. diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index 4ebe9fd476f5a..fd05c29daba91 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -99,7 +99,7 @@ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser_Matcher { * Constructor. * * @param WP_CSS_Compound_Selector $self_selector The selector in the final position. - * @param array{WP_CSS_Type_Selector, string}[]|null $selectors The context selectors. + * @param array{WP_CSS_Type_Selector, string}[]|null $context_selectors The context selectors. */ private function __construct( WP_CSS_Compound_Selector $self_selector, @@ -246,7 +246,7 @@ public static function parse( string $input, int &$offset ) { return null; } - /** @var array{WP_CSS_Compound_Selector, string} */ + /** @var array{WP_CSS_Type_Selector, string} */ $selector_pair = array( $self_selector->type_selector, $combinator ); $selectors[] = $selector_pair; $self_selector = $next_selector; diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index edc6a841a859a..c9eb936ff7371 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -51,9 +51,7 @@ * - ID selectors (e.g. `#unique-id`) * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) - * - The following combinators. Only type (element) selectors are allowed in non-final position: - * - descendant (e.g. `el .descendant`) - * - child (`el > .child`) + * - Compound selectors (e.g. `div.class-name#id[attr]`) * * Unsupported selector syntax: * - Pseudo-element selectors (`::before`) diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index 180ecba98bacf..e020bbb664d3f 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -123,7 +123,7 @@ final protected static function parse_hash_token( string $input, int &$offset ): * the is not a part of the selector grammar. That * case is treated as failure to parse and null is returned. * - * @return string|null + * @return string|null The parsed string token value, or null if parsing failed. */ final protected static function parse_string( string $input, int &$offset ): ?string { if ( $offset >= strlen( $input ) ) { @@ -278,7 +278,7 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s * * https://www.w3.org/TR/css-syntax-3/#consume-name * - * @return string|null + * @return string|null The parsed identifier name, or null if parsing failed. */ final protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { From 57b5128ba1381a15d5c8df6157083b7b14490e74 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 17:19:06 +0200 Subject: [PATCH 143/336] Avoid redundant get_attribute call --- src/wp-includes/html-api/class-wp-css-id-selector.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 1d3b7f1f85d16..e2e47a24d1e6c 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -48,8 +48,8 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { $case_insensitive = $processor->is_quirks_mode(); return $case_insensitive - ? 0 === strcasecmp( $id, $this->id ) - : $processor->get_attribute( 'id' ) === $this->id; + ? 0 === strcasecmp( $id, $this->id ) + : $id === $this->id; } /** From 5f06f84c2eb45594c5f7147bb28cb5865893a43a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 15 Jul 2025 17:34:05 +0200 Subject: [PATCH 144/336] Reformat some documentation --- .../html-api/class-wp-css-selector-parser-matcher.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php index e020bbb664d3f..aa8153ca27f45 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php @@ -316,7 +316,10 @@ final protected static function parse_ident( string $input, int &$offset ): ?str * Checks for two valid escape codepoints. * * > 4.3.8. Check if two code points are a valid escape - * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. + * > This section describes how to check if two code points are a valid escape. The algorithm + * > described here can be called explicitly with two code points, or can be called with the + * > input stream itself. In the latter case, the two code points in question are the current + * > input code point and the next input code point, in that order. * > * > Note: This algorithm will not consume any additional code point. * > @@ -328,7 +331,7 @@ final protected static function parse_ident( string $input, int &$offset ): ?str * * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape * - * @todo this does not check whether the second codepoint is valid. + * @todo The second codepoint is not checked for validity. * * @param string $input The input string. * @param int $offset The byte offset in the string. From e559f6a18a410c75f73ab566412a5c83fce210eb Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 15 Jul 2025 13:53:52 -0500 Subject: [PATCH 145/336] Rename files and remove duplicate character --- .../class-wp-css-attribute-selector.php | 0 .../{ => css}/class-wp-css-class-selector.php | 0 .../class-wp-css-complex-selector-list.php | 0 .../class-wp-css-complex-selector.php | 0 .../class-wp-css-compound-selector-list.php | 0 .../class-wp-css-compound-selector.php | 0 .../{ => css}/class-wp-css-id-selector.php | 0 .../class-wp-css-selector-parser-matcher.php | 2 +- .../{ => css}/class-wp-css-type-selector.php | 0 src/wp-settings.php | 18 +++++++++--------- 10 files changed, 10 insertions(+), 10 deletions(-) rename src/wp-includes/html-api/{ => css}/class-wp-css-attribute-selector.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-class-selector.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-complex-selector-list.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-complex-selector.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-compound-selector-list.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-compound-selector.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-id-selector.php (100%) rename src/wp-includes/html-api/{ => css}/class-wp-css-selector-parser-matcher.php (99%) rename src/wp-includes/html-api/{ => css}/class-wp-css-type-selector.php (100%) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-attribute-selector.php rename to src/wp-includes/html-api/css/class-wp-css-attribute-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/css/class-wp-css-class-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-class-selector.php rename to src/wp-includes/html-api/css/class-wp-css-class-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/css/class-wp-css-complex-selector-list.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-complex-selector-list.php rename to src/wp-includes/html-api/css/class-wp-css-complex-selector-list.php diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/css/class-wp-css-complex-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-complex-selector.php rename to src/wp-includes/html-api/css/class-wp-css-complex-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-compound-selector-list.php rename to src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/css/class-wp-css-compound-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-compound-selector.php rename to src/wp-includes/html-api/css/class-wp-css-compound-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/css/class-wp-css-id-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-id-selector.php rename to src/wp-includes/html-api/css/class-wp-css-id-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php similarity index 99% rename from src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php rename to src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php index aa8153ca27f45..cac314e8b6c27 100644 --- a/src/wp-includes/html-api/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php @@ -482,7 +482,7 @@ final protected static function normalize_selector_input( string $input ): strin * This list includes \f. * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. */ - $input = trim( $input, " \t\r\n\r\f" ); + $input = trim( $input, " \t\r\n\f" ); /* * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/css/class-wp-css-type-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-type-selector.php rename to src/wp-includes/html-api/css/class-wp-css-type-selector.php diff --git a/src/wp-settings.php b/src/wp-settings.php index d4a209893d5cd..9337af05da3ae 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -266,15 +266,15 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-selector-parser-matcher.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-attribute-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-class-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-id-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-type-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-compound-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-complex-selector.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-compound-selector-list.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-complex-selector-list.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-selector-parser-matcher.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-attribute-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-class-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-id-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-type-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-compound-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-complex-selector.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-compound-selector-list.php'; +require ABSPATH . WPINC . '/html-api/css/class-wp-css-complex-selector-list.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.php'; From 0a87b201785b6a85420e666aaac6b3b7811ba92d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 16:58:41 +0200 Subject: [PATCH 146/336] CSS selector: Fix off-by-one rejecting [a=b] at end of selector The length guard before the attribute matcher required 4 remaining bytes where the minimum valid tail `=x]` is 3, so a valid exact-match attribute selector with a single-character unquoted value at the end of the selector string (e.g. `[a=b]`) was wrongly rejected as unparseable. Relax the guard from `>=` to `>`. All reads after the guard are bounded: the operator reads touch at most offset+1, and every later read re-checks the length itself. Adds the exact-fit valid case and invalid cases at the same boundary (`[a=]`, `[a~=]`, `[a==b]`, `[a=1]`) to the parse tests, plus an assertNotNull so parse failures report cleanly instead of erroring on a null property read. Found by the CSS selector fuzzer (tools/css-selector-fuzz, Bug 3 in FINDINGS.md). (cherry picked from commit 16d03e2c5fd49d79f413562d88d7e6e36774618a) --- .../html-api/css/class-wp-css-attribute-selector.php | 2 +- tests/phpunit/tests/html-api/wpCssAttributeSelector.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php index a63dfaba66b61..aee6f09a41088 100644 --- a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php @@ -275,7 +275,7 @@ public static function parse( string $input, int &$offset ) { } // need to match at least `=x]` at this point - if ( $updated_offset + 3 >= strlen( $input ) ) { + if ( $updated_offset + 3 > strlen( $input ) ) { return null; } diff --git a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php index e574cedd1876b..08880a7311acf 100644 --- a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php @@ -31,6 +31,7 @@ public function test_parse_attribute( if ( null === $expected_name ) { $this->assertNull( $result ); } else { + $this->assertNotNull( $result, "Failed to parse attribute selector: {$input}" ); $this->assertSame( $expected_name, $result->name ); $this->assertSame( $expected_matcher, $result->matcher ); $this->assertSame( $expected_value, $result->value ); @@ -53,6 +54,7 @@ public static function data_attribute_selectors(): array { '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[a=b]' => array( '[a=b]', 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, 'b', null, '' ), '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), @@ -79,6 +81,10 @@ public static function data_attribute_selectors(): array { 'Invalid: [*| att]' => array( '[*| att]' ), 'Invalid: [att * =]' => array( '[att * =]' ), 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [a=]' => array( '[a=]' ), + 'Invalid: [a~=]' => array( '[a~=]' ), + 'Invalid: [a==b]' => array( '[a==b]' ), + 'Invalid: [a=1]' => array( '[a=1]' ), 'Invalid: [att=val ' => array( '[att=val ' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), From 989e18da8a9ed5bdd829e66f41ec63f086bf0aca Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 17:11:19 +0200 Subject: [PATCH 147/336] CSS selector: Empty-operand substring attribute matchers match nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Selectors level 4, the substring attribute matchers with an empty value — [x^=""], [x$=""], [x*=""] — represent nothing and must never match. The matcher instead matched any element carrying the attribute (prefix and contains) or an element whose attribute value was exactly empty (suffix). Add an early return for the empty operand on those three matchers, before the case modifier and boolean-attribute normalization so [x^="" i] and valueless attributes are covered too. [x=""], [x|=""], and [x~=""] are unaffected and remain spec-correct: exact and hyphen matchers may match an empty value, and the one-of matcher already matched nothing because a whitespace-delimited list never yields an empty item. Tests pin all of these, including |= against a hyphen-prefixed value. https://www.w3.org/TR/selectors-4/#attribute-substrings Found by the CSS selector fuzzer (tools/css-selector-fuzz, Bug 2 in FINDINGS.md). (cherry picked from commit 0cefeb2fc8fd6b255498c6dc0438b7334698b975) --- .../css/class-wp-css-attribute-selector.php | 18 ++++++++++++++++++ .../tests/html-api/wpHtmlProcessor-select.php | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php index aee6f09a41088..8b631966bb579 100644 --- a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php @@ -163,6 +163,24 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { return true; } + /* + * The substring matchers match nothing when the value is empty: + * + * > If "val" is the empty string then the selector does not represent anything. + * + * https://www.w3.org/TR/selectors-4/#attribute-substrings + */ + if ( + '' === $this->value && + ( + self::MATCH_PREFIXED_BY === $this->matcher || + self::MATCH_SUFFIXED_BY === $this->matcher || + self::MATCH_CONTAINS === $this->matcher + ) + ) { + return false; + } + if ( true === $attr_value ) { $attr_value = ''; } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 003e65e69ebce..0fb5788efce7f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -53,6 +53,16 @@ public static function data_selectors(): array { 'any child matches all children' => array( '

', 'section > *', 2 ), 'multiple complex selectors' => array( '

', 'section > div p > i', 1 ), + + // Per Selectors-4, the substring matchers ^= $= *= match nothing when the value + // is empty. ~= also matches nothing: an empty string is never a list item. + 'empty value ^= matches nothing' => array( '', '[x^=""]', 0 ), + 'empty value $= matches nothing' => array( '', '[x$=""]', 0 ), + 'empty value *= matches nothing' => array( '', '[x*=""]', 0 ), + 'empty value ~= matches nothing' => array( '', '[x~=""]', 0 ), + 'empty value ^= i matches nothing' => array( '', '[x^="" i]', 0 ), + 'empty value = matches empty' => array( '', '[x=""]', 1 ), + 'empty value |= matches empty or hyphen-prefixed' => array( '', '[x|=""]', 2 ), ); } From 5ac71eeb3fc0ce524d6ed481d7840191397337d1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 10:43:31 +0200 Subject: [PATCH 148/336] CSS selector: Align test data provider arrows per WPCS phpcbf reports 40 WordPress.Arrays.MultipleStatementAlignment warnings in this file's data providers, and the coding-standards workflow runs phpcs over the test suite without -n, so warnings fail CI. Pure whitespace; no test changes. (cherry picked from commit 3db43eaef2107421f156e6ac2a29478fe6e4b2fd) --- .../html-api/wpCssSelectorParserMatcher.php | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 4f3c1f73390fe..37f26ec8f3b92 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -54,38 +54,38 @@ public static function test_is_ident_start_codepoint( string $input, int $offset */ public static function data_idents(): array { return array( - 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), - 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), - 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), - 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), - 'escape after multibyte character' => array( 'Ü\\sup', 'Üsup', '' ), - 'escape after multibyte characters' => array( 'ÜÜ\\sup', 'ÜÜsup', '' ), + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escape after multibyte character' => array( 'Ü\\sup', 'Üsup', '' ), + 'escape after multibyte characters' => array( 'ÜÜ\\sup', 'ÜÜsup', '' ), 'hex escape after multibyte character' => array( 'Ü\\31 23', 'Ü123', '' ), - 'escaped space' => array( '\\ x', ' x', '' ), - 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), - 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), - - 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), - 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), - 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), - 'hex tab' => array( '\\9', "\t", '' ), - 'hex a' => array( '\\61 bc', 'abc', '' ), - 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), - - 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), - 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), - 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), - 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), - 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), - 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), - 'can start with -ident' => array( '-ident', '-ident', '' ), - 'can start with --anything' => array( '--anything', '--anything', '' ), - 'can start with ---anything' => array( '--_anything', '--_anything', '' ), - 'can start with --1anything' => array( '--1anything', '--1anything', '' ), - 'can start with -\31 23' => array( '-\31 23', '-123', '' ), - 'can start with --\31 23' => array( '--\31 23', '--123', '' ), - 'ident ends before ]' => array( 'ident]', 'ident', ']' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), + 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), + + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + + 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), + 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), + 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), + 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), + 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), + 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + 'can start with -ident' => array( '-ident', '-ident', '' ), + 'can start with --anything' => array( '--anything', '--anything', '' ), + 'can start with ---anything' => array( '--_anything', '--_anything', '' ), + 'can start with --1anything' => array( '--1anything', '--1anything', '' ), + 'can start with -\31 23' => array( '-\31 23', '-123', '' ), + 'can start with --\31 23' => array( '--\31 23', '--123', '' ), + 'ident ends before ]' => array( 'ident]', 'ident', ']' ), /* * > EOF @@ -93,19 +93,19 @@ public static function data_idents(): array { * * https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point */ - 'escape at EOF' => array( 'foo\\', "foo\u{fffd}", '' ), - 'lone escape at EOF' => array( '\\', "\u{fffd}", '' ), - 'hyphen then escape at EOF' => array( '-\\', "-\u{fffd}", '' ), + 'escape at EOF' => array( 'foo\\', "foo\u{fffd}", '' ), + 'lone escape at EOF' => array( '\\', "\u{fffd}", '' ), + 'hyphen then escape at EOF' => array( '-\\', "-\u{fffd}", '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: bad start >' => array( '>ident' ), - 'Invalid: bad start [' => array( '[ident' ), - 'Invalid: bad start #' => array( '#ident' ), - 'Invalid: bad start " "' => array( ' ident' ), - 'Invalid: bad start 1' => array( '1ident' ), - 'Invalid: bad start -1' => array( '-1ident' ), - 'Invalid: bad start -' => array( '-' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: bad start >' => array( '>ident' ), + 'Invalid: bad start [' => array( '[ident' ), + 'Invalid: bad start #' => array( '#ident' ), + 'Invalid: bad start " "' => array( ' ident' ), + 'Invalid: bad start 1' => array( '1ident' ), + 'Invalid: bad start -1' => array( '-1ident' ), + 'Invalid: bad start -' => array( '-' ), ); } From aed6cfb4aaddb874c26c30fdf53d568a98d8c17c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 17:26:10 +0200 Subject: [PATCH 149/336] CSS selector: Decode identity escapes at the byte offset, not char index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consume_escaped_codepoint() read the escaped codepoint for non-hex (identity) escapes with mb_substr( $input, $offset, 1 ), but $offset is a byte offset while mb_substr()'s second argument is a character index. Any multibyte content earlier in the selector string shifts the read one character right per continuation byte, decoding the wrong codepoint: 'Ü\sup' parsed as ident 'Üuup', and the corruption threads across an entire selector list ('#ÜÜÜ,\sup #x' parsed the second selector's type as ' up'). Depending on the mis-decoded codepoint this also caused spurious parse failures of valid selectors. Hex escapes were already byte-correct and are unaffected. Read the codepoint from the byte offset instead. ASCII-only inputs are byte-for-byte unchanged, and the returned codepoint's byte length keeps the offset advancing exactly past it. Adds parse_ident and parse_string cases pinning identity escapes after multibyte characters, plus a hex-escape control. Found by the CSS selector fuzzer (tools/css-selector-fuzz, Bug 1 in FINDINGS.md). (cherry picked from commit 7419a9fef6c6c23d14b8ae9c04ae1c91834824b0) --- .../html-api/css/class-wp-css-selector-parser-matcher.php | 3 ++- tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php index cac314e8b6c27..dc8aa8d018446 100644 --- a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php @@ -250,7 +250,8 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s return $codepoint_char; } - $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); + // $offset is a byte offset; mb_substr() expects a character offset. + $codepoint_char = mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' ); $offset += strlen( $codepoint_char ); return $codepoint_char; } diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 29372172da2b1..ffa02b17b7f0d 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -58,6 +58,9 @@ public static function data_idents(): array { 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escape after multibyte character' => array( 'Ü\\sup', 'Üsup', '' ), + 'escape after multibyte characters' => array( 'ÜÜ\\sup', 'ÜÜsup', '' ), + 'hex escape after multibyte character' => array( 'Ü\\31 23', 'Ü123', '' ), 'escaped space' => array( '\\ x', ' x', '' ), 'escaped emoji' => array( '\\😍', '😍', '' ), 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), @@ -158,6 +161,7 @@ public static function data_strings(): array { "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'Ü\\sup'" => array( "'Ü\\sup'", 'Üsup', '' ), "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), From e0356cad899bd09610827c125d4815a9dc02697b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 22:38:35 +0200 Subject: [PATCH 150/336] CSS selector: Backslash at end of input is a valid escape (U+FFFD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CSS Syntax 3, a backslash followed by EOF is a valid escape in ident context -- §4.3.8 rejects only a newline as the second code point, and EOF is not a newline -- and consuming it returns U+FFFD REPLACEMENT CHARACTER (§4.3.7). WP rejected the whole selector: next_two_are_valid_escape() required a code point after the backslash, so '.foo\' parsed to null instead of the class "foo\u{FFFD}". Fix: consume_escaped_codepoint() returns U+FFFD at EOF without advancing, and next_two_are_valid_escape() accepts a backslash as the final byte. String context is unaffected: parse_string() guards EOF itself before consuming an escape, preserving the §4.3.5 'do nothing' EOF rule ('foo\ still parses to foo). Review of the fix surfaced a second bug in the same family: normalize_selector_input() trimmed *trailing* whitespace before tokenizing, so '.foo\ ' (escaped space: the valid, unmatchable class 'foo ') and ".foo\\n" (invalid escape: must be rejected) both collapsed to '.foo\' and matched elements with class "foo\u{FFFD}" -- a wrong-match-set bug, where before the EOF-escape fix the collapse was a harmless fail-safe rejection. Now only leading whitespace is stripped; the grammar already consumes insignificant trailing whitespace via parse_whitespace() in both selector-list parsers. Verified against lexbor: '.foo\' matches class "foo\u{FFFD}", lone '\' parses as type U+FFFD and matches nothing, '.foo\ ' is valid and matches nothing, and the LF/CR/FF escape variants are rejected -- exact agreement on all probes. (NEXT-STEPS.md 'candidate finding 4', now confirmed and closed.) (cherry picked from commit 203858bbd470a2b7a8eeaf7f7579d85eeced3264) --- .../class-wp-css-selector-parser-matcher.php | 29 ++++++++++++++++--- .../tests/html-api/wpCssClassSelector.php | 1 + .../html-api/wpCssCompoundSelectorList.php | 23 +++++++++++++++ .../tests/html-api/wpCssIdSelector.php | 1 + .../html-api/wpCssSelectorParserMatcher.php | 10 +++++++ .../tests/html-api/wpCssTypeSelector.php | 1 + .../html-api/wpHtmlTagProcessor-select.php | 20 +++++++++++++ 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php index dc8aa8d018446..23d14d01c673b 100644 --- a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php @@ -209,6 +209,14 @@ final protected static function parse_string( string $input, int &$offset ): ?st * @return string */ final protected static function consume_escaped_codepoint( $input, &$offset ): string { + /* + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + */ + if ( $offset >= strlen( $input ) ) { + return "\u{FFFD}"; + } + $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); if ( $hex_length > 0 ) { /** @@ -339,10 +347,17 @@ final protected static function parse_ident( string $input, int &$offset ): ?str * @return bool True if the next two codepoints are a valid escape, otherwise false. */ final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { - if ( $offset + 1 >= strlen( $input ) ) { + if ( $offset >= strlen( $input ) ) { return false; } - return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; + + /* + * The second code point may be EOF. EOF is not a newline, so a + * backslash at the end of input is a valid escape; consuming it + * produces U+FFFD REPLACEMENT CHARACTER. + */ + return '\\' === $input[ $offset ] && + ( $offset + 1 >= strlen( $input ) || "\n" !== $input[ $offset + 1 ] ); } /** @@ -481,9 +496,15 @@ final protected static function normalize_selector_input( string $input ): strin * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… * * This list includes \f. - * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. + * + * Only leading whitespace is removed here. Trailing whitespace may be + * significant: a backslash may escape a final whitespace code point + * into an ident (`.foo\ ` is the class `foo `), and a backslash + * before a final newline is an invalid escape, while a backslash at + * the end of input is a valid escape that decodes to U+FFFD. The + * selector grammar consumes insignificant trailing whitespace itself. */ - $input = trim( $input, " \t\r\n\f" ); + $input = ltrim( $input, " \t\r\n\f" ); /* * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. diff --git a/tests/phpunit/tests/html-api/wpCssClassSelector.php b/tests/phpunit/tests/html-api/wpCssClassSelector.php index 9646d05da23d5..3328b047fa143 100644 --- a/tests/phpunit/tests/html-api/wpCssClassSelector.php +++ b/tests/phpunit/tests/html-api/wpCssClassSelector.php @@ -40,6 +40,7 @@ public static function data_class_selectors(): array { 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), 'escaped .\31 23' => array( '.\\31 23', '123', '' ), 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), + 'escape at EOF .foo\\' => array( '.foo\\', "foo\u{fffd}", '' ), 'not class foo' => array( 'foo' ), 'not class #bar' => array( '#bar' ), diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 8f1d3dfb88a45..c71aa09596d8f 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -40,6 +40,29 @@ public function test_parse_invalid_selector_list2() { $this->assertNull( $result ); } + /** + * An escaped whitespace code point at the end of input belongs to the + * ident and must survive input normalization: `.foo\ ` is the valid + * class `foo ` (with a space), not a backslash at the end of input. + * + * @ticket 62653 + */ + public function test_parse_escaped_whitespace_at_end_of_input() { + $result = WP_CSS_Compound_Selector_List::from_selectors( '.foo\\ ' ); + $this->assertNotNull( $result ); + } + + /** + * A backslash before a newline is not a valid escape; at the end of + * input it must not be mistaken for trimmable trailing whitespace. + * + * @ticket 62653 + */ + public function test_parse_escape_before_newline_at_end_of_input_is_invalid() { + $result = WP_CSS_Compound_Selector_List::from_selectors( ".foo\\\n" ); + $this->assertNull( $result ); + } + /** * @ticket 62653 */ diff --git a/tests/phpunit/tests/html-api/wpCssIdSelector.php b/tests/phpunit/tests/html-api/wpCssIdSelector.php index 6dc2e5461ea03..03694fa4456e5 100644 --- a/tests/phpunit/tests/html-api/wpCssIdSelector.php +++ b/tests/phpunit/tests/html-api/wpCssIdSelector.php @@ -40,6 +40,7 @@ public static function data_id_selectors(): array { 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), 'escaped #\31 23' => array( '#\\31 23', '123', '' ), 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), + 'escape at EOF #foo\\' => array( '#foo\\', "foo\u{fffd}", '' ), // Invalid 'not ID foo' => array( 'foo' ), diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index ffa02b17b7f0d..4f3c1f73390fe 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -87,6 +87,16 @@ public static function data_idents(): array { 'can start with --\31 23' => array( '--\31 23', '--123', '' ), 'ident ends before ]' => array( 'ident]', 'ident', ']' ), + /* + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point + */ + 'escape at EOF' => array( 'foo\\', "foo\u{fffd}", '' ), + 'lone escape at EOF' => array( '\\', "\u{fffd}", '' ), + 'hyphen then escape at EOF' => array( '-\\', "-\u{fffd}", '' ), + // Invalid 'Invalid: (empty string)' => array( '' ), 'Invalid: bad start >' => array( '>ident' ), diff --git a/tests/phpunit/tests/html-api/wpCssTypeSelector.php b/tests/phpunit/tests/html-api/wpCssTypeSelector.php index 23d5f5517453a..94ae49bff474a 100644 --- a/tests/phpunit/tests/html-api/wpCssTypeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssTypeSelector.php @@ -40,6 +40,7 @@ public static function data_type_selectors(): array { 'a' => array( 'a', 'a', '' ), 'div.class' => array( 'div.class', 'div', '.class' ), 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + 'escape at EOF foo\\' => array( 'foo\\', "foo\u{fffd}", '' ), // Invalid 'Invalid: (empty string)' => array( '' ), diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 1d09c61b4760d..a2cc231150b09 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -75,6 +75,16 @@ public static function data_selectors(): array { 'attribute contains insensitive' => array( '

', '[att*="x"i]', 1 ), 'attribute contains sensitive mod' => array( '

', '[att*="x"s]', 1 ), + /* + * An escaped trailing whitespace code point is part of the ident, + * not trailing whitespace: `.foo\ ` is the class `foo ` (with a + * space). Class attribute values are whitespace-separated token + * lists, so such a class can never match. It must NOT be confused + * with a backslash at the end of input, which decodes to U+FFFD. + */ + 'escaped space at end' => array( "

", '.foo\\ ', 0 ), + 'escaped tab at end' => array( "
", ".foo\\\t", 0 ), + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), ); @@ -102,6 +112,16 @@ public static function data_invalid_selectors(): array { 'complex descendant' => array( 'div *' ), 'complex child' => array( 'div > *' ), 'invalid selector' => array( '[invalid!selector]' ), + + /* + * A backslash before a newline at the end of input is not a valid + * escape and is not trailing whitespace: the selector is invalid. + * The CR and FF variants are normalized to a newline before + * tokenizing. + */ + 'escape before newline at end' => array( ".foo\\\n" ), + 'escape before CR at end' => array( ".foo\\\r" ), + 'escape before FF at end' => array( ".foo\\\f" ), ); } } From 481e5a4e857d44b13961aa35984b3fc926fb95a4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 23:02:16 +0200 Subject: [PATCH 151/336] CSS selector: End of input auto-closes an open attribute selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CSS Syntax 3 §5.4.8/§4.3.5, tokenization auto-closes unterminated simple blocks and unterminated strings at EOF (a parse error, but the block/string is returned), and the selector grammar then applies to the block contents. So '[att=val' is the same selector as '[att=val]', and '[att="a b' carries the string value 'a b'. WP rejected all of these with null. The attribute parser now treats the end of input like a closing ']' at the two positions where the grammar is complete (after the name, and after the value/modifier), and the early length guards that required room for a closing bracket are relaxed accordingly. Truncation inside the grammar itself is still invalid: '[', '[a=', '[a~', '[a=b x', and a comma inside the open block ('[a=b, div') all stay null. Escape interplay (verified per spec and in Chromium): '[a=b\' carries the value "b\u{FFFD}" (escape at EOF in ident context), while '[a="b\' carries 'b' (backslash-then-EOF in a string 'does nothing'). '[a\]' parses as a presence selector for the attribute 'a]' (the escaped ']' joins the ident and EOF closes the block). Chromium agrees with every accepted and rejected form above. lexbor rejects all EOF-truncated forms (it does not implement the auto-close rule) and diverges from browsers and the spec here; the fuzzer's lexbor differential is unaffected because it compares canonical re-renders, which always include the closing bracket. (cherry picked from commit 5eea359bd5da8d0f8bc01a510237eec4177a5c1e) --- .../css/class-wp-css-attribute-selector.php | 64 ++++++++++--------- .../tests/html-api/wpCssAttributeSelector.php | 33 +++++++++- .../html-api/wpHtmlTagProcessor-select.php | 18 ++++++ 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php index 8b631966bb579..8fd3b5b3cbd35 100644 --- a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php @@ -258,14 +258,21 @@ private function whitespace_delimited_list( string $input ): Generator { * * To create an instance of this class, use the {@see WP_CSS_Compound_Selector_List::from_selectors()} method. * + * The end of input acts like a closing `]`: tokenization auto-closes + * unterminated simple blocks (and unterminated strings) at EOF, so + * `[att=val` is the same selector as `[att=val]`. Truncation inside the + * selector grammar itself (e.g. `[` or `[att=`) is still invalid. + * + * https://www.w3.org/TR/css-syntax-3/#consume-simple-block + * * @param string $input The selector string. * @param int $offset The offset into the string. The offset is passed by reference and * will be updated if the parse is successful. * @return static|null The selector instance, or null if the parse was unsuccessful. */ public static function parse( string $input, int &$offset ) { - // Need at least 3 bytes [x] - if ( $offset + 2 >= strlen( $input ) ) { + // Need at least 2 bytes `[x`; the closing `]` may be supplied by the end of input. + if ( $offset + 1 >= strlen( $input ) ) { return null; } @@ -283,8 +290,10 @@ public static function parse( string $input, int &$offset ) { } self::parse_whitespace( $input, $updated_offset ); + // The end of input auto-closes the attribute selector. if ( $updated_offset >= strlen( $input ) ) { - return null; + $offset = $updated_offset; + return new WP_CSS_Attribute_Selector( $attr_name ); } if ( ']' === $input[ $updated_offset ] ) { @@ -292,15 +301,10 @@ public static function parse( string $input, int &$offset ) { return new WP_CSS_Attribute_Selector( $attr_name ); } - // need to match at least `=x]` at this point - if ( $updated_offset + 3 > strlen( $input ) ) { - return null; - } - if ( '=' === $input[ $updated_offset ] ) { ++$updated_offset; $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; - } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + } elseif ( $updated_offset + 1 < strlen( $input ) && '=' === $input[ $updated_offset + 1 ] ) { switch ( $input[ $updated_offset ] ) { case '~': $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; @@ -339,32 +343,34 @@ public static function parse( string $input, int &$offset ) { } self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } $attr_modifier = null; - switch ( $input[ $updated_offset ] ) { - case 'i': - case 'I': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; - ++$updated_offset; - break; - - case 's': - case 'S': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; - ++$updated_offset; - break; - } + if ( $updated_offset < strlen( $input ) ) { + switch ( $input[ $updated_offset ] ) { + case 'i': + case 'I': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + case 'S': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } - if ( null !== $attr_modifier ) { - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); } } + // The end of input auto-closes the attribute selector. + if ( $updated_offset >= strlen( $input ) ) { + $offset = $updated_offset; + return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + if ( ']' === $input[ $updated_offset ] ) { $offset = $updated_offset + 1; return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); diff --git a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php index 08880a7311acf..99051f2cc971c 100644 --- a/tests/phpunit/tests/html-api/wpCssAttributeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssAttributeSelector.php @@ -70,10 +70,36 @@ public static function data_attribute_selectors(): array { '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + /* + * The end of input closes an open attribute selector: tokenization + * auto-closes unterminated simple blocks (and strings) at EOF. + * + * https://www.w3.org/TR/css-syntax-3/#consume-simple-block + */ + 'EOF [foo' => array( '[foo', 'foo', null, null, null, '' ), + 'EOF [ \n foo' => array( "[ \n foo", 'foo', null, null, null, '' ), + 'EOF [foo ' => array( '[foo ', 'foo', null, null, null, '' ), + 'EOF [a=b' => array( '[a=b', 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, 'b', null, '' ), + 'EOF [att=val ' => array( '[att=val ', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', null, '' ), + 'EOF [a="b' => array( '[a="b', 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, 'b', null, '' ), + "EOF [a='b" => array( "[a='b", 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, 'b', null, '' ), + 'EOF [a="b\\' => array( '[a="b\\', 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, 'b', null, '' ), + 'EOF [a=b\\' => array( '[a=b\\', 'a', WP_CSS_Attribute_Selector::MATCH_EXACT, "b\u{FFFD}", null, '' ), + 'EOF [a^=b' => array( '[a^=b', 'a', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'b', null, '' ), + 'EOF [att=val i' => array( '[att=val i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + 'EOF [att=val i ' => array( '[att=val i ', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + 'EOF [att="val"s' => array( '[att="val"s', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + // Invalid 'Invalid: (empty string)' => array( '' ), 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [' => array( '[' ), + 'Invalid: [ ' => array( '[ ' ), + 'Invalid: [a=' => array( '[a=' ), + 'Invalid: [a= ' => array( '[a= ' ), + 'Invalid: [a~' => array( '[a~' ), + 'Invalid: [a=b x' => array( '[a=b x' ), + 'Invalid: [a i' => array( '[a i' ), 'Invalid: [#foo]' => array( '[#foo]' ), 'Invalid: [*|*]' => array( '[*|*]' ), 'Invalid: [ns|*]' => array( '[ns|*]' ), @@ -85,12 +111,13 @@ public static function data_attribute_selectors(): array { 'Invalid: [a~=]' => array( '[a~=]' ), 'Invalid: [a==b]' => array( '[a==b]' ), 'Invalid: [a=1]' => array( '[a=1]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [a=1' => array( '[a=1' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), + "Invalid: [att='val\\n" => array( "[att='val\n" ), 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + 'Invalid: [att="val"ix ' => array( '[att="val"ix ' ), ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index a2cc231150b09..d09a6e350256d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -85,6 +85,16 @@ public static function data_selectors(): array { 'escaped space at end' => array( "
", '.foo\\ ', 0 ), 'escaped tab at end' => array( "
", ".foo\\\t", 0 ), + /* + * The end of input closes an open attribute selector ( and an + * unterminated string ): tokenization auto-closes simple blocks + * at EOF. + */ + 'EOF-truncated attribute presence' => array( '
', '[att', 1 ), + 'EOF-truncated attribute value' => array( '
', '[att=val', 1 ), + 'EOF-truncated quoted value' => array( '
', '[att="a b', 1 ), + 'EOF-truncated with modifier' => array( '
', '[att=val i', 1 ), + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), ); @@ -122,6 +132,14 @@ public static function data_invalid_selectors(): array { 'escape before newline at end' => array( ".foo\\\n" ), 'escape before CR at end' => array( ".foo\\\r" ), 'escape before FF at end' => array( ".foo\\\f" ), + + /* + * EOF auto-closes an open attribute selector block, but + * grammar-level truncation is still invalid. + */ + 'truncated matcher without value' => array( '[a=' ), + 'truncated half matcher' => array( '[a~' ), + 'lone open bracket' => array( '[' ), ); } } From 8bf3e522df5e8879525d5adf1d6ff4ef686b2990 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 23:54:25 +0200 Subject: [PATCH 152/336] CSS selector: Implement HTML's case-insensitive attribute value list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTML defines 46 attributes (type, rel, lang, dir, media, hreflang, http-equiv, ...) whose values must match ASCII case-insensitively in attribute selectors on an HTML element when the selector carries no i/s modifier: https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors WP honored only the explicit modifiers, so [type=TEXT] silently failed to match — a wrong match set rather than a refusal, invisible to callers. The matcher now folds case when all three hold: no modifier on the selector, the element is in the html namespace (per the processor's get_namespace()), and the lowercased attribute name is in the list. An explicit s modifier still forces case-sensitive matching, per Selectors 4 §6.3: 'the UA must match the value case-sensitively ... regardless of document language rules.' All six matchers and |='s hyphen check honor the rule via the existing case-insensitive comparison branches. Namespace scoping follows the spec's 'on an HTML element' wording: SVG/MathML elements keep case-sensitive matching, while elements at HTML integration points (e.g. inside ) fold, since they are html-namespace. Verified in Chromium, which agrees on the integration point but also folds plain SVG-namespace elements, diverging from the spec's scoping; WP follows the spec. The standalone Tag Processor tracks no namespaces and folds everywhere — the same class of approximation as its ancestor-blind matching. The review panel machine-diffed both list constants against the live spec (exact, in spec order). (cherry picked from commit 40640d173ec680a05cf06bd9829273ab2a8805ae) --- .../css/class-wp-css-attribute-selector.php | 71 ++++++++++++++++++- .../tests/html-api/wpHtmlProcessor-select.php | 11 +++ .../html-api/wpHtmlTagProcessor-select.php | 17 +++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php index 8fd3b5b3cbd35..134e68104811f 100644 --- a/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/css/class-wp-css-attribute-selector.php @@ -90,6 +90,66 @@ final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser_Matcher { */ const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; + /** + * The attributes whose values HTML defines as ASCII case-insensitive + * for attribute selectors on an HTML element, when the selector has no + * `i`/`s` modifier. An explicit `s` modifier forces case-sensitive + * matching even for these attributes; elements in other namespaces + * (SVG, MathML) are unaffected. + * + * The names are stored as array keys for constant-time lookup. + * + * @see https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors + */ + const HTML_CASE_INSENSITIVE_ATTRIBUTE_VALUES = array( + 'accept' => true, + 'accept-charset' => true, + 'align' => true, + 'alink' => true, + 'axis' => true, + 'bgcolor' => true, + 'charset' => true, + 'checked' => true, + 'clear' => true, + 'codetype' => true, + 'color' => true, + 'compact' => true, + 'declare' => true, + 'defer' => true, + 'dir' => true, + 'direction' => true, + 'disabled' => true, + 'enctype' => true, + 'face' => true, + 'frame' => true, + 'hreflang' => true, + 'http-equiv' => true, + 'lang' => true, + 'language' => true, + 'link' => true, + 'media' => true, + 'method' => true, + 'multiple' => true, + 'nohref' => true, + 'noresize' => true, + 'noshade' => true, + 'nowrap' => true, + 'readonly' => true, + 'rel' => true, + 'rev' => true, + 'rules' => true, + 'scope' => true, + 'scrolling' => true, + 'selected' => true, + 'shape' => true, + 'target' => true, + 'text' => true, + 'type' => true, + 'valign' => true, + 'valuetype' => true, + 'vlink' => true, + ); + /** * The name of the attribute to match. * @@ -185,7 +245,16 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { $attr_value = ''; } - $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + /* + * Without an explicit modifier, HTML defines some attributes' values + * as ASCII case-insensitive on HTML elements. An explicit `s` + * modifier forces case-sensitive matching even for those. + */ + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier || ( + null === $this->modifier && + 'html' === $processor->get_namespace() && + isset( self::HTML_CASE_INSENSITIVE_ATTRIBUTE_VALUES[ strtolower( $this->name ) ] ) + ); switch ( $this->matcher ) { case self::MATCH_EXACT: diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 0fb5788efce7f..fcb1acf3fa7d6 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -63,6 +63,17 @@ public static function data_selectors(): array { 'empty value ^= i matches nothing' => array( '', '[x^="" i]', 0 ), 'empty value = matches empty' => array( '', '[x=""]', 1 ), 'empty value |= matches empty or hyphen-prefixed' => array( '', '[x|=""]', 2 ), + + /* + * HTML's case-insensitive attribute value list applies to + * "an HTML element in an HTML document": a foreign element with + * the same attribute name keeps case-sensitive matching. + * ( Chromium applies the list to foreign elements as well, + * diverging from the HTML specification here. ) + * + * https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors + */ + 'HTML-namespace-only attribute case-insensitivity' => array( '', '[type=TEXT]', 1 ), ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index d09a6e350256d..1062c66a40253 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -95,6 +95,23 @@ public static function data_selectors(): array { 'EOF-truncated quoted value' => array( '
', '[att="a b', 1 ), 'EOF-truncated with modifier' => array( '
', '[att=val i', 1 ), + /* + * HTML defines a set of attributes whose values must match ASCII + * case-insensitively in selectors when no modifier is present. + * An explicit `s` modifier still forces case-sensitive matching. + * Attributes outside the list stay case-sensitive by default. + * + * https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors + */ + 'HTML insensitive attribute =' => array( '', '[type=TEXT]', 2 ), + 'HTML insensitive attribute ~=' => array( '', '[rel~=nofollow]', 1 ), + 'HTML insensitive attribute ^=' => array( '', '[media^=screen]', 1 ), + 'HTML insensitive attribute |=' => array( '', '[hreflang|=en]', 1 ), + 'HTML insensitive attribute s mod' => array( '', '[type=text s]', 1 ), + 'HTML insensitive attribute i mod' => array( '', '[type=text i]', 2 ), + 'unlisted attribute stays sensitive' => array( '', '[data-type=TEXT]', 1 ), + 'listed attribute name is matched case-insensitively in the list' => array( '', '[TYPE=TEXT]', 1 ), + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), ); From 00537a266f562edbf062cef6ccf7204c6e2dabc8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 10:48:22 +0200 Subject: [PATCH 153/336] CSS selector: Decode identity escapes without copying the input tail The identity arm of consume_escaped_codepoint() read one character via mb_substr( substr( $input, $offset ), 0, 1 ), copying the entire remaining input per escape: O(n^2) over selectors composed of escapes, plus an O(n) temporary allocation each time. Size the code point in place instead with the bounded scanner _wp_scan_utf8( $input, $at, $invalid_length, 4, 1 ) from compat-utf8.php (WP 6.9, loaded unconditionally before the HTML API), then copy at most 4 bytes. Escapes of invalid UTF-8 fall through to the literal previous mb_substr() line, so behavior is preserved by construction under every mb_substitute_character setting; that fallback remains O(tail) per call, accepted for developer-supplied selectors. _wp_utf8_codepoint_span() is deliberately not used: it leaves the scanner's ASCII fast-path unbounded, which is quadratic again (noted in-code). 200KB of repeated \g through parse_ident: 180 ms before, 45 ms after, with linear scaling after (47/90/180 ms at 200/400/800KB; previously ~4x per doubling) and half the peak memory. Escape pin coverage grows to 14 cases: 2/3/4-byte characters including at end of input, NUL, and each invalid-byte class (lone continuation, overlong lead, invalid lead, truncated 3/4-byte, encoded surrogate, above U+10FFFF), with expectations probe-verified against the pre-change implementation. Adversarial review: equivalence reviewer ran ~74M differential old-vs-new cases (exhaustive byte-class boundaries at every offset, random fuzz, non-default mb_substitute_character) with 0 mismatches; perf reviewer independently reproduced the quadratic-before / linear-after curves; integration reviewer verified load order (including SHORTINIT), private-function precedent, and phpcs. All approved. Gates: full html-api PHPUnit group green (1654 tests), fuzzer 5000 seeds 0 failures. (cherry picked from commit 9d82c1ccafbf4a8db412f7873e01eb55a9b0c627) --- .../class-wp-css-selector-parser-matcher.php | 29 +++++++++++++++- .../html-api/wpCssSelectorParserMatcher.php | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php index 23d14d01c673b..0fe7d6c608ed0 100644 --- a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php @@ -258,7 +258,34 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s return $codepoint_char; } - // $offset is a byte offset; mb_substr() expects a character offset. + /* + * Find the byte length of the code point at $offset without copying the rest + * of the input: a code point is at most 4 bytes, so the scan is bounded and + * an escape of valid UTF-8 decodes in O(1) regardless of selector length. + * Escaped invalid bytes take the mb_substr() fallback below, which copies + * the remaining input on each call. + * + * `_wp_utf8_codepoint_span()` is not suitable here: it does not bound the + * scan, so its ASCII fast-path reads to the end of the input on every call, + * which is quadratic over a selector composed of escapes. + */ + $at = $offset; + $invalid_length = 0; + _wp_scan_utf8( $input, $at, $invalid_length, 4, 1 ); + if ( $at > $offset ) { + $codepoint_char = substr( $input, $offset, $at - $offset ); + $offset = $at; + return $codepoint_char; + } + + /* + * The bytes at $offset are not valid UTF-8. Decode with mbstring to + * preserve the parser's long-standing behavior for invalid input, which + * depends on `mb_substitute_character()`: with the default setting the + * substitute character `?` is returned and one byte is consumed. + * + * $offset is a byte offset; mb_substr() expects a character offset. + */ $codepoint_char = mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' ); $offset += strlen( $codepoint_char ); return $codepoint_char; diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 37f26ec8f3b92..6971fbbdec7fc 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -97,6 +97,39 @@ public static function data_idents(): array { 'lone escape at EOF' => array( '\\', "\u{fffd}", '' ), 'hyphen then escape at EOF' => array( '-\\', "-\u{fffd}", '' ), + // Identity escapes of multibyte characters, by UTF-8 sequence length. + 'escaped 2-byte character' => array( "\\\u{FC}z", "\u{FC}z", '' ), + 'escaped 3-byte character' => array( "\\\u{270F}z", "\u{270F}z", '' ), + 'escaped 4-byte character' => array( "\\\u{1F0A1}z", "\u{1F0A1}z", '' ), + 'escaped 2-byte character at EOF' => array( "a\\\u{FC}", "a\u{FC}", '' ), + 'escaped 3-byte character at EOF' => array( "a\\\u{270F}", "a\u{270F}", '' ), + 'escaped 4-byte character at EOF' => array( "a\\\u{1F0A1}", "a\u{1F0A1}", '' ), + + /* + * An escaped NUL byte passes through this low-level helper unchanged. + * This is unreachable through the public selector API, where + * normalize_selector_input() replaces NUL with U+FFFD before parsing. + */ + 'escaped NUL byte' => array( "a\\\x00z", "a\x00z", '' ), + + /* + * Identity escapes of invalid UTF-8 byte sequences. + * + * These inputs are not valid UTF-8. The escaped invalid byte decodes via + * mbstring substitution (`?` under the default `mb_substitute_character()` + * setting) and one byte is consumed; any continuation bytes that follow + * are appended verbatim by the ident-code-point path. These cases pin the + * current behavior under the default mbstring settings; they do not + * assert it is desirable. + */ + 'escaped lone continuation byte' => array( "a\\\x80z", 'a?z', '' ), + 'escaped overlong lead 0xC0' => array( "a\\\xC0\xAFz", "a?\xAFz", '' ), + 'escaped invalid lead 0xF5' => array( "a\\\xF5z", 'a?z', '' ), + 'escaped truncated 3-byte sequence' => array( "a\\\xE2\x80z", "a?\x80z", '' ), + 'escaped truncated 4-byte at EOF' => array( "a\\\xF0\x9F\x82", "a?\x9F\x82", '' ), + 'escaped UTF-8-encoded surrogate' => array( "a\\\xED\xA0\x80z", "a?\xA0\x80z", '' ), + 'escaped sequence above U+10FFFF' => array( "a\\\xF4\x90\x80\x80z", "a?\x90\x80\x80z", '' ), + // Invalid 'Invalid: (empty string)' => array( '' ), 'Invalid: bad start >' => array( '>ident' ), From 1b148d7a274a880fdca9aa733fc3b66bb59b9150 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 11:45:35 +0200 Subject: [PATCH 154/336] CSS selector: Pin a canary mb_substitute_character in escape tests Decoding an identity escape of invalid UTF-8 leaks the process-global mb_substitute_character() setting into parse results: the substitute character is returned and the offset advances by the byte length of the substitute, not of the invalid sequence. Under the default '?' this is nearly invisible; under a multibyte substitute it swallows following characters and can push the offset past the end of the input. Pin the setting to a distinctive canary -- U+2603 SNOWMAN -- in set_up()/tear_down() and rewrite the seven invalid-byte pins to the canary expectations, making the dependence unmistakable: five cases show the trailing 'z' being eaten, and a dedicated test asserts the offset overrun that the rest-of-input assertion cannot see (substr() returns '' both at and past the end). A differential run of all provider cases under canary/default/'none' confirms exactly these seven react to the setting; everything else is independent of it. These pins document the leak, not endorse it. They are the ready-made red suite for the planned fix: decoding invalid bytes to U+FFFD per maximal subpart (CSS Syntax 3 section 3.2 via the WHATWG Encoding Standard) makes the outputs setting-independent and flips every one of these expectations. Adversarial review approved; full html-api group green (1654 tests) with the substitute character verified restored after the run. (cherry picked from commit 9b0b1df6ec47b2937e9eb49a4388b08bb0f5e104) --- .../html-api/wpCssSelectorParserMatcher.php | 78 +++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 6971fbbdec7fc..707afb5d4a133 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -13,8 +13,27 @@ class Tests_HtmlApi_WpCssSelectorParserMatcher extends WP_UnitTestCase { private $test_class; + /** + * Preserves the `mb_substitute_character()` setting around each test. + * + * @var int|string + */ + private $original_substitute_character; + public function set_up(): void { parent::set_up(); + + /* + * Decoding invalid UTF-8 in identity escapes leaks the process-global + * `mb_substitute_character()` setting into parse results. Pin it to a + * distinctive character — U+2603 SNOWMAN (☃) — so that any dependence + * on the setting is unmistakable in test expectations, rather than a + * `?` that looks like an intentional placeholder. The escape-decode + * cases below document the leak; a parser that decodes invalid bytes + * to U+FFFD per CSS Syntax §3.2 would be unaffected by this setting. + */ + $this->original_substitute_character = mb_substitute_character(); + mb_substitute_character( 0x2603 ); $this->test_class = new class() extends WP_CSS_Selector_Parser_Matcher { public function matches( $processor ): bool { throw new Error( 'Matches called on test class.' ); @@ -47,6 +66,11 @@ public static function test_is_ident_start_codepoint( string $input, int $offset }; } + public function tear_down(): void { + mb_substitute_character( $this->original_substitute_character ); + parent::tear_down(); + } + /** * Data provider. * @@ -115,20 +139,30 @@ public static function data_idents(): array { /* * Identity escapes of invalid UTF-8 byte sequences. * - * These inputs are not valid UTF-8. The escaped invalid byte decodes via - * mbstring substitution (`?` under the default `mb_substitute_character()` - * setting) and one byte is consumed; any continuation bytes that follow - * are appended verbatim by the ident-code-point path. These cases pin the - * current behavior under the default mbstring settings; they do not - * assert it is desirable. + * These inputs are not valid UTF-8. The escaped invalid byte decodes to + * the process-global `mb_substitute_character()` — pinned to U+2603 (☃) + * in set_up() to make the dependence visible — and the offset then + * advances by the byte length of *the substitute character* (3 bytes + * for ☃, 1 byte for the default `?`), not of the invalid sequence. + * The expectations below show the damage: following characters are + * swallowed (the `z` in most cases) and the offset can even overrun + * the end of the input (the lone-continuation and 0xF5 cases end with + * the offset one byte past the end; see the dedicated offset test). + * + * These cases pin the current behavior to document the leak, not to + * endorse it. CSS Syntax §3.2 decodes the input byte stream per the + * WHATWG Encoding Standard, which replaces each maximal subpart of an + * invalid sequence with U+FFFD; when the parser does that, these + * expectations flip to U+FFFD outputs that are independent of + * `mb_substitute_character()`. */ - 'escaped lone continuation byte' => array( "a\\\x80z", 'a?z', '' ), - 'escaped overlong lead 0xC0' => array( "a\\\xC0\xAFz", "a?\xAFz", '' ), - 'escaped invalid lead 0xF5' => array( "a\\\xF5z", 'a?z', '' ), - 'escaped truncated 3-byte sequence' => array( "a\\\xE2\x80z", "a?\x80z", '' ), - 'escaped truncated 4-byte at EOF' => array( "a\\\xF0\x9F\x82", "a?\x9F\x82", '' ), - 'escaped UTF-8-encoded surrogate' => array( "a\\\xED\xA0\x80z", "a?\xA0\x80z", '' ), - 'escaped sequence above U+10FFFF' => array( "a\\\xF4\x90\x80\x80z", "a?\x90\x80\x80z", '' ), + 'escaped lone continuation byte' => array( "a\\\x80z", "a\u{2603}", '' ), + 'escaped overlong lead 0xC0' => array( "a\\\xC0\xAFz", "a\u{2603}", '' ), + 'escaped invalid lead 0xF5' => array( "a\\\xF5z", "a\u{2603}", '' ), + 'escaped truncated 3-byte sequence' => array( "a\\\xE2\x80z", "a\u{2603}", '' ), + 'escaped truncated 4-byte at EOF' => array( "a\\\xF0\x9F\x82", "a\u{2603}", '' ), + 'escaped UTF-8-encoded surrogate' => array( "a\\\xED\xA0\x80z", "a\u{2603}z", '' ), + 'escaped sequence above U+10FFFF' => array( "a\\\xF4\x90\x80\x80z", "a\u{2603}\x80z", '' ), // Invalid 'Invalid: (empty string)' => array( '' ), @@ -169,6 +203,24 @@ public function test_parse_ident( string $input, ?string $expected = null, ?stri } } + /** + * The rest-of-input assertion above cannot distinguish an offset at the end + * of the input from one past it (`substr()` returns '' for both), so the + * offset overrun caused by decoding an escaped invalid byte to a multibyte + * substitute character is pinned explicitly here: the 3-byte ☃ advance over + * the 1-byte invalid sequence leaves the offset one byte past the end. + * Decoding invalid bytes to U+FFFD with maximal-subpart consumption would + * turn this case into `"a\u{FFFD}z"` with the offset at the end of input. + */ + public function test_parse_ident_escaped_invalid_byte_overruns_offset() { + $input = "a\\\x80z"; + $offset = 0; + $result = $this->test_class::test_parse_ident( $input, $offset ); + + $this->assertSame( "a\u{2603}", $result, 'Ident did not match.' ); + $this->assertSame( strlen( $input ) + 1, $offset, 'Offset did not overrun the end of input.' ); + } + /** * @ticket 62653 * From b26128e09b4bf4b24a0771a29ebdf76c7f566e3f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 18:07:47 +0200 Subject: [PATCH 155/336] CSS selector: Scrub invalid UTF-8 selector input to U+FFFD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selector strings are UTF-8 text. from_selectors() now decodes the input byte stream before parsing: normalize_selector_input() replaces each maximal subpart of an ill-formed byte sequence with U+FFFD via wp_scrub_utf8() (WP 6.9), per the byte-decoding step CSS Syntax 3 section 3.2 defines through the WHATWG Encoding Standard's UTF-8 decoder. A replaced selector is almost always a developer mistake (mojibake, double encoding) that would otherwise yield a silently empty match set, so the replacement also reports _doing_it_wrong(), named "::from_selectors" via late static binding. The mb_substitute_character() leak in consume_escaped_codepoint() dies structurally: with all public input scrubbed, the identity arm's mb_substr() fallback became unreachable through from_selectors() and is replaced by a deterministic decode for direct parse() callers — consume the maximal subpart the existing _wp_scan_utf8() call already reported and return one U+FFFD, consistent with the scrub. This also removes the remaining O(tail)-per-escape copy for invalid bytes. Design decision: reject (wp_is_valid_utf8() -> null) and raw byte passthrough were both considered and discarded by a three-persona adversarial design review; scrub is the option that stays stable under both the current raw value getters and a future where the getters scrub their return values. An escape-arm-only U+FFFD decode was ruled out unanimously: it would break the identity property that escaping a non-special code point is equivalent to writing it unescaped. The known divergence is pinned in a test: a scrubbed selector cannot match raw invalid bytes in a document (the Tag Processor reports raw bytes); if the HTML API value getters are ever changed to scrub, that pin flips to a match and must be updated in the same change. The compound-list class docblock gains a "Text Encoding" section recording the contract. Tests: the seven U+2603-canary escape pins flip to maximal-subpart U+FFFD expectations, and the canary is retained permanently — its job inverted from documenting the leak to proving setting-independence (a reintroduced mb_substitute_character dependence fails eight tests). New coverage: scrub + notice through from_selectors() on both list classes (the complex-list test pins the late-static-binding notice name), a lone invalid byte parsing as a U+FFFD type selector, the notice firing even when the scrubbed selector is rejected by the grammar, string-token invalid-byte decode, identity-escape equivalence and U+FFFD matching through select(), and the raw-document-bytes no-match pin (deliberately unique 0xC1 byte: select() memoizes the last parsed selector string, so a unique selector guarantees the parse-time notice under any test order). Adversarial review: three hostile reviewers. The equivalence reviewer verified the decode against an independent reference WHATWG UTF-8 decoder (exhaustive 1-2-byte tails, ~204k boundary-alphabet 3-4-byte tails, 100k random; zero mismatches, all under a U+2603 canary), scrub idempotence and ordering-neutrality (200k cases), and the notice-name propagation. The test reviewer killed four core mutations against the suite and demonstrated two test defects (select()-cache coupling, an unpinned complex-list notice name), both fixed before commit. The integration reviewer verified worker-model equivalence and determinism (10000 fuzz seeds clean). All approved. Gates: full html-api group green (1665 tests), fuzzer 5000 seeds 0 failures, self-check OK, phpcs clean. (cherry picked from commit 598ed6f363daec3d954cf22cee71b7f98f1f17c2) --- .../class-wp-css-compound-selector-list.php | 24 ++++ .../class-wp-css-selector-parser-matcher.php | 46 +++++-- .../html-api/wpCssComplexSelectorList.php | 14 ++ .../html-api/wpCssCompoundSelectorList.php | 60 +++++++++ .../html-api/wpCssSelectorParserMatcher.php | 125 +++++++++--------- .../html-api/wpHtmlTagProcessor-select.php | 64 ++++++++- 6 files changed, 254 insertions(+), 79 deletions(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php index c9eb936ff7371..4042b1bc94f3e 100644 --- a/src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/css/class-wp-css-compound-selector-list.php @@ -18,6 +18,26 @@ * It takes a CSS selector string and returns an instance of itself or `null` if the selector * is invalid or unsupported. * + * ### Text Encoding + * + * Selector strings are UTF-8 text. Ill-formed byte sequences in a selector string are + * replaced with U+FFFD REPLACEMENT CHARACTER (visually "�"), one per maximal subpart, + * before parsing — following the byte-decoding step CSS Syntax specifies for input + * byte streams (via the WHATWG Encoding Standard's UTF-8 decoder), not a browser's + * `querySelector`, which receives already-decoded strings and can never see ill-formed + * input. The replacement also triggers a `_doing_it_wrong()` notice, since a selector + * containing ill-formed bytes is almost always a developer error and, once replaced, + * matches only literal U+FFFD characters in the document. + * + * Note that the document side is byte-oriented and unscrubbed: the Tag Processor + * reports attribute values and class names with their raw bytes intact (see the + * "Text Encoding" section of {@see WP_HTML_Tag_Processor}). A selector containing + * ill-formed bytes therefore never matches those same raw bytes in a document. + * Selectors for non-UTF-8 ( but ASCII-compatible ) documents can only reliably match + * non-ASCII values by converting the selector and document to UTF-8 beforehand. + * + * @link https://www.w3.org/TR/css-syntax-3/#input-byte-stream + * * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. * @@ -115,6 +135,10 @@ protected function __construct( array $selectors ) { * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. * + * The selector string must be UTF-8: ill-formed byte sequences are replaced with + * U+FFFD per maximal subpart before parsing and reported with `_doing_it_wrong()`. + * See the "Text Encoding" section of the class documentation. + * * @param string $input CSS selectors. * @return static|null */ diff --git a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php index 0fe7d6c608ed0..14d9d28a771cc 100644 --- a/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php +++ b/src/wp-includes/html-api/css/class-wp-css-selector-parser-matcher.php @@ -262,8 +262,6 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s * Find the byte length of the code point at $offset without copying the rest * of the input: a code point is at most 4 bytes, so the scan is bounded and * an escape of valid UTF-8 decodes in O(1) regardless of selector length. - * Escaped invalid bytes take the mb_substr() fallback below, which copies - * the remaining input on each call. * * `_wp_utf8_codepoint_span()` is not suitable here: it does not bound the * scan, so its ASCII fast-path reads to the end of the input on every call, @@ -279,16 +277,15 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): s } /* - * The bytes at $offset are not valid UTF-8. Decode with mbstring to - * preserve the parser's long-standing behavior for invalid input, which - * depends on `mb_substitute_character()`: with the default setting the - * substitute character `?` is returned and one byte is consumed. - * - * $offset is a byte offset; mb_substr() expects a character offset. + * The bytes at $offset are not valid UTF-8, which can only happen when + * `parse()` was called directly with un-normalized input: the public + * `from_selectors()` API replaces ill-formed byte sequences with U+FFFD + * before parsing. Decode consistently with that normalization — consume + * the maximal subpart of the ill-formed sequence, whose length the scan + * above reported, and return a single U+FFFD. */ - $codepoint_char = mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' ); - $offset += strlen( $codepoint_char ); - return $codepoint_char; + $offset += max( 1, $invalid_length ); + return "\u{FFFD}"; } /** @@ -511,14 +508,39 @@ final protected static function check_if_three_code_points_would_start_an_ident_ } /** - * Normalizes selector input for processing. + * Normalizes selector input for processing: decodes the byte stream as + * UTF-8 ( replacing ill-formed sequences with U+FFFD ), then filters the + * code points per the input-preprocessing rules. * + * @see https://www.w3.org/TR/css-syntax-3/#input-byte-stream * @see https://www.w3.org/TR/css-syntax-3/#input-preprocessing * * @param string $input The selector string. * @return string The normalized selector string. */ final protected static function normalize_selector_input( string $input ): string { + /* + * > The input byte stream defines the byte stream that comprises a style sheet. + * > To decode bytes into a stream of code points… + * + * Selector strings are UTF-8 text. Decoding replaces each maximal + * subpart of an ill-formed byte sequence with U+FFFD REPLACEMENT + * CHARACTER (�), per the WHATWG Encoding Standard's UTF-8 decoder. + * The replaced selector is unlikely to match the elements the + * developer intended, so the replacement also reports a notice. + * + * https://www.w3.org/TR/css-syntax-3/#input-byte-stream + */ + $scrubbed = wp_scrub_utf8( $input ); + if ( $scrubbed !== $input ) { + _doing_it_wrong( + get_called_class() . '::from_selectors', + 'Selector strings must be valid UTF-8: ill-formed byte sequences were replaced with U+FFFD (�), which is unlikely to match the intended elements.', + '{WP_VERSION}' + ); + $input = $scrubbed; + } + /* * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… * diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index edf912e97f490..b85f788f98f0d 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -48,4 +48,18 @@ public function test_parse_empty_selector_list() { $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } + + /** + * The invalid-UTF-8 scrub notice reports the called class: through this + * class it must be named WP_CSS_Complex_Selector_List::from_selectors, + * not the WP_CSS_Compound_Selector_List parent where from_selectors() + * and the scrub are implemented. The fuzzer's notice model depends on + * the per-class name. + * + * @expectedIncorrectUsage WP_CSS_Complex_Selector_List::from_selectors + */ + public function test_invalid_utf8_scrub_notice_reports_the_called_class() { + $result = WP_CSS_Complex_Selector_List::from_selectors( "el \xC2.child" ); + $this->assertNotNull( $result, 'Selector with invalid UTF-8 should parse after scrubbing.' ); + } } diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index c71aa09596d8f..33149c22ed400 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -80,4 +80,64 @@ public function test_unsupported_complex_selector() { $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } + + /** + * Selector strings are UTF-8 text: invalid byte sequences are replaced + * with U+FFFD per maximal subpart (CSS Syntax §3.2 via the WHATWG + * Encoding Standard) before parsing, so the selector parses rather than + * being rejected. The replacement is almost certainly not what the + * developer meant, so it also triggers `_doing_it_wrong()`. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_invalid_utf8_is_scrubbed_to_replacement_character_and_notifies() { + $result = WP_CSS_Compound_Selector_List::from_selectors( ".B\xFCcher" ); + $this->assertNotNull( $result, 'Selector with invalid UTF-8 should parse after scrubbing.' ); + } + + /** + * Valid UTF-8 — including a literal U+FFFD — must parse without any + * incorrect-usage notice: scrubbing is the identity function on valid + * input. + */ + public function test_valid_utf8_with_literal_replacement_character_is_not_notified() { + $result = WP_CSS_Compound_Selector_List::from_selectors( ".B\u{FFFD}cher" ); + $this->assertNotNull( $result, 'Selector containing a literal U+FFFD should parse.' ); + } + + /** + * The whole input is scrubbed uniformly, so a selector list with invalid + * bytes in one of several selectors still parses as a list. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_invalid_utf8_in_selector_list_is_scrubbed() { + $result = WP_CSS_Compound_Selector_List::from_selectors( ".ok, .B\xE2\x8Ccher" ); + $this->assertNotNull( $result, 'Selector list with invalid UTF-8 should parse after scrubbing.' ); + } + + /** + * A selector consisting of nothing but an invalid byte parses: it scrubs + * to U+FFFD, which is an ident-start code point and therefore a valid + * type selector. Surprising, but it follows from the scrub running + * before tokenization — the parser never sees the invalid byte. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_lone_invalid_byte_parses_as_replacement_character_type_selector() { + $result = WP_CSS_Compound_Selector_List::from_selectors( "\x80" ); + $this->assertNotNull( $result, 'A lone invalid byte should parse as a U+FFFD type selector.' ); + } + + /** + * The scrub notice reports the byte replacement, which happens before + * parsing — it fires even when the scrubbed selector is then rejected + * by the grammar. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_invalid_utf8_notice_fires_even_when_selector_is_rejected() { + $result = WP_CSS_Compound_Selector_List::from_selectors( "\x80 div" ); + $this->assertNull( $result, 'Descendant combinators are unsupported by the compound list; the scrubbed selector should still be rejected.' ); + } } diff --git a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php index 707afb5d4a133..181519b3cbed3 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php +++ b/tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php @@ -24,13 +24,12 @@ public function set_up(): void { parent::set_up(); /* - * Decoding invalid UTF-8 in identity escapes leaks the process-global - * `mb_substitute_character()` setting into parse results. Pin it to a - * distinctive character — U+2603 SNOWMAN (☃) — so that any dependence - * on the setting is unmistakable in test expectations, rather than a - * `?` that looks like an intentional placeholder. The escape-decode - * cases below document the leak; a parser that decodes invalid bytes - * to U+FFFD per CSS Syntax §3.2 would be unaffected by this setting. + * Parse results must not depend on the process-global + * `mb_substitute_character()` setting. Pin it to a distinctive + * character — U+2603 SNOWMAN (☃) — for every test in this file: any + * dependence on the setting would surface as a ☃ in the results + * rather than the expected U+FFFD. This guards the invalid-byte + * decode, which once leaked the setting into parse results. */ $this->original_substitute_character = mb_substitute_character(); mb_substitute_character( 0x2603 ); @@ -139,30 +138,25 @@ public static function data_idents(): array { /* * Identity escapes of invalid UTF-8 byte sequences. * - * These inputs are not valid UTF-8. The escaped invalid byte decodes to - * the process-global `mb_substitute_character()` — pinned to U+2603 (☃) - * in set_up() to make the dependence visible — and the offset then - * advances by the byte length of *the substitute character* (3 bytes - * for ☃, 1 byte for the default `?`), not of the invalid sequence. - * The expectations below show the damage: following characters are - * swallowed (the `z` in most cases) and the offset can even overrun - * the end of the input (the lone-continuation and 0xF5 cases end with - * the offset one byte past the end; see the dedicated offset test). - * - * These cases pin the current behavior to document the leak, not to - * endorse it. CSS Syntax §3.2 decodes the input byte stream per the - * WHATWG Encoding Standard, which replaces each maximal subpart of an - * invalid sequence with U+FFFD; when the parser does that, these - * expectations flip to U+FFFD outputs that are independent of - * `mb_substitute_character()`. + * These inputs are not valid UTF-8, which can only reach the parser + * through a direct `parse()` call: the public `from_selectors()` API + * replaces invalid byte sequences with U+FFFD before parsing. On + * this un-normalized path the escape decodes the maximal subpart of + * the invalid sequence (CSS Syntax §3.2 via the WHATWG Encoding + * Standard) to a single U+FFFD — independent of the + * `mb_substitute_character()` setting, which set_up() pins to ☃ + * precisely to prove that independence. Invalid bytes *after* the + * escaped subpart are not escaped; they pass through this low-level + * helper raw, exactly as unescaped invalid bytes do (the 0xAF, + * 0xA0 0x80, and 0x90 0x80 0x80 tails below). */ - 'escaped lone continuation byte' => array( "a\\\x80z", "a\u{2603}", '' ), - 'escaped overlong lead 0xC0' => array( "a\\\xC0\xAFz", "a\u{2603}", '' ), - 'escaped invalid lead 0xF5' => array( "a\\\xF5z", "a\u{2603}", '' ), - 'escaped truncated 3-byte sequence' => array( "a\\\xE2\x80z", "a\u{2603}", '' ), - 'escaped truncated 4-byte at EOF' => array( "a\\\xF0\x9F\x82", "a\u{2603}", '' ), - 'escaped UTF-8-encoded surrogate' => array( "a\\\xED\xA0\x80z", "a\u{2603}z", '' ), - 'escaped sequence above U+10FFFF' => array( "a\\\xF4\x90\x80\x80z", "a\u{2603}\x80z", '' ), + 'escaped lone continuation byte' => array( "a\\\x80z", "a\u{FFFD}z", '' ), + 'escaped overlong lead 0xC0' => array( "a\\\xC0\xAFz", "a\u{FFFD}\xAFz", '' ), + 'escaped invalid lead 0xF5' => array( "a\\\xF5z", "a\u{FFFD}z", '' ), + 'escaped truncated 3-byte sequence' => array( "a\\\xE2\x80z", "a\u{FFFD}z", '' ), + 'escaped truncated 4-byte at EOF' => array( "a\\\xF0\x9F\x82", "a\u{FFFD}", '' ), + 'escaped UTF-8-encoded surrogate' => array( "a\\\xED\xA0\x80z", "a\u{FFFD}\xA0\x80z", '' ), + 'escaped sequence above U+10FFFF' => array( "a\\\xF4\x90\x80\x80z", "a\u{FFFD}\x90\x80\x80z", '' ), // Invalid 'Invalid: (empty string)' => array( '' ), @@ -206,19 +200,19 @@ public function test_parse_ident( string $input, ?string $expected = null, ?stri /** * The rest-of-input assertion above cannot distinguish an offset at the end * of the input from one past it (`substr()` returns '' for both), so the - * offset overrun caused by decoding an escaped invalid byte to a multibyte - * substitute character is pinned explicitly here: the 3-byte ☃ advance over - * the 1-byte invalid sequence leaves the offset one byte past the end. - * Decoding invalid bytes to U+FFFD with maximal-subpart consumption would - * turn this case into `"a\u{FFFD}z"` with the offset at the end of input. + * offset arithmetic of the invalid-byte decode is pinned explicitly here: + * the escape consumes exactly the 1-byte maximal subpart and the following + * `z`, leaving the offset at — never past — the end of the input. (The + * previous `mb_substr()`-based decode advanced by the byte length of the + * substitute character and overran the end by one byte under the ☃ canary.) */ - public function test_parse_ident_escaped_invalid_byte_overruns_offset() { + public function test_parse_ident_escaped_invalid_byte_does_not_overrun_offset() { $input = "a\\\x80z"; $offset = 0; $result = $this->test_class::test_parse_ident( $input, $offset ); - $this->assertSame( "a\u{2603}", $result, 'Ident did not match.' ); - $this->assertSame( strlen( $input ) + 1, $offset, 'Offset did not overrun the end of input.' ); + $this->assertSame( "a\u{FFFD}z", $result, 'Ident did not match.' ); + $this->assertSame( strlen( $input ), $offset, 'Offset should stop exactly at the end of input.' ); } /** @@ -244,35 +238,44 @@ public function test_parse_string( string $input, ?string $expected = null, ?str */ public static function data_strings(): array { return array( - '"foo"' => array( '"foo"', 'foo', '' ), - '"foo"after' => array( '"foo"after', 'foo', 'after' ), - '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), - '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), - "'foo'" => array( "'foo'", 'foo', '' ), - "'foo'after" => array( "'foo'after", 'foo', 'after' ), - "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), - "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'Ü\\sup'" => array( "'Ü\\sup'", 'Üsup', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), - "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), - "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), - "'Ü\\sup'" => array( "'Ü\\sup'", 'Üsup', '' ), - "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), - "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), - "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + "'foo\\" => array( "'foo\\", 'foo', '' ), - "'foo\\" => array( "'foo\\", 'foo', '' ), + /* + * Invalid UTF-8 in string context, reachable only via a direct + * parse() call ( from_selectors() scrubs first ): an escaped + * invalid byte decodes its maximal subpart to U+FFFD, exactly as + * in ident context; raw invalid bytes pass through unexamined. + */ + 'string with escaped invalid byte' => array( "'a\\\xC0z'", "a\u{FFFD}z", '' ), + 'string with raw invalid byte' => array( "'a\xC0z'", "a\xC0z", '' ), - '"' => array( '"', '', '' ), - '"\\"' => array( '"\\"', '"', '' ), - '"missing close' => array( '"missing close', 'missing close', '' ), + '"' => array( '"', '', '' ), + '"\\"' => array( '"\\"', '"', '' ), + '"missing close' => array( '"missing close', 'missing close', '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: .foo' => array( '.foo' ), - 'Invalid: #foo' => array( '#foo' ), - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 1062c66a40253..96bb8e1b4457d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -136,9 +136,9 @@ public function test_invalid_selector( string $selector ) { */ public static function data_invalid_selectors(): array { return array( - 'complex descendant' => array( 'div *' ), - 'complex child' => array( 'div > *' ), - 'invalid selector' => array( '[invalid!selector]' ), + 'complex descendant' => array( 'div *' ), + 'complex child' => array( 'div > *' ), + 'invalid selector' => array( '[invalid!selector]' ), /* * A backslash before a newline at the end of input is not a valid @@ -146,9 +146,9 @@ public static function data_invalid_selectors(): array { * The CR and FF variants are normalized to a newline before * tokenizing. */ - 'escape before newline at end' => array( ".foo\\\n" ), - 'escape before CR at end' => array( ".foo\\\r" ), - 'escape before FF at end' => array( ".foo\\\f" ), + 'escape before newline at end' => array( ".foo\\\n" ), + 'escape before CR at end' => array( ".foo\\\r" ), + 'escape before FF at end' => array( ".foo\\\f" ), /* * EOF auto-closes an open attribute selector block, but @@ -159,4 +159,56 @@ public static function data_invalid_selectors(): array { 'lone open bracket' => array( '[' ), ); } + + /** + * Selector strings are UTF-8 text: invalid byte sequences are replaced + * with U+FFFD per maximal subpart before parsing. A selector containing + * invalid bytes therefore matches a literal U+FFFD in the document, and + * an identity escape of an invalid byte is equivalent to the same byte + * unescaped — both are scrubbed before tokenization. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_select_scrubbed_selector_matches_replacement_character() { + $html = "
"; + + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( + $processor->select( ".a\xC0b" ), + 'Scrubbed selector should match the replacement character in the document.' + ); + + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( + $processor->select( ".a\\\xC0b" ), + 'An identity escape of an invalid byte should be equivalent to the unescaped byte.' + ); + } + + /** + * A selector containing invalid bytes can never match those same raw + * bytes in a document: the selector side is scrubbed to U+FFFD while + * the Tag Processor reports raw document bytes untouched. + * + * This pins a deliberate, documented divergence. If the HTML API value + * getters (get_attribute(), class_list(), …) are ever changed to scrub + * invalid UTF-8 in their return values, both sides become U+FFFD and + * this case flips to a match — update this expectation in the same + * change. + * + * The selector byte (0xC1) is unique within this file on purpose: + * select() memoizes the most recently parsed selector string, so the + * scrub notice only fires when this test's selector was not already + * parsed by an earlier test. A unique selector string guarantees a + * fresh parse regardless of test order. + * + * @expectedIncorrectUsage WP_CSS_Compound_Selector_List::from_selectors + */ + public function test_select_scrubbed_selector_does_not_match_raw_invalid_document_bytes() { + $processor = new WP_HTML_Tag_Processor( "
" ); + $this->assertFalse( + $processor->select( ".a\xC1b" ), + 'Scrubbed selector should not match raw invalid bytes in the document.' + ); + } } From 74081b16926f1041c5f1a95f88fd39dd05cf8934 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 12:49:25 +0200 Subject: [PATCH 156/336] initial css fuzzer --- tools/css-selector-fuzz/FINDINGS.md | 114 +++ tools/css-selector-fuzz/NEXT-STEPS.md | 178 ++++ tools/css-selector-fuzz/README.md | 70 ++ tools/css-selector-fuzz/lib/AstExtractor.php | 149 +++ tools/css-selector-fuzz/lib/Bootstrap.php | 63 ++ .../lib/DocumentGenerator.php | 450 +++++++++ tools/css-selector-fuzz/lib/Prng.php | 65 ++ .../lib/ReferenceMatcher.php | 253 +++++ .../lib/SelectorGenerator.php | 899 ++++++++++++++++++ tools/css-selector-fuzz/lib/Worker.php | 589 ++++++++++++ tools/css-selector-fuzz/lib/autoload.php | 9 + tools/css-selector-fuzz/lib/util.php | 157 +++ tools/css-selector-fuzz/lib/wp-stubs.php | 62 ++ tools/css-selector-fuzz/replay.php | 91 ++ tools/css-selector-fuzz/runner.php | 267 ++++++ tools/css-selector-fuzz/tests/self-check.php | 131 +++ tools/css-selector-fuzz/worker.php | 34 + 17 files changed, 3581 insertions(+) create mode 100644 tools/css-selector-fuzz/FINDINGS.md create mode 100644 tools/css-selector-fuzz/NEXT-STEPS.md create mode 100644 tools/css-selector-fuzz/README.md create mode 100644 tools/css-selector-fuzz/lib/AstExtractor.php create mode 100644 tools/css-selector-fuzz/lib/Bootstrap.php create mode 100644 tools/css-selector-fuzz/lib/DocumentGenerator.php create mode 100644 tools/css-selector-fuzz/lib/Prng.php create mode 100644 tools/css-selector-fuzz/lib/ReferenceMatcher.php create mode 100644 tools/css-selector-fuzz/lib/SelectorGenerator.php create mode 100644 tools/css-selector-fuzz/lib/Worker.php create mode 100644 tools/css-selector-fuzz/lib/autoload.php create mode 100644 tools/css-selector-fuzz/lib/util.php create mode 100644 tools/css-selector-fuzz/lib/wp-stubs.php create mode 100644 tools/css-selector-fuzz/replay.php create mode 100644 tools/css-selector-fuzz/runner.php create mode 100644 tools/css-selector-fuzz/tests/self-check.php create mode 100644 tools/css-selector-fuzz/worker.php diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md new file mode 100644 index 0000000000000..981b33ef41d80 --- /dev/null +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -0,0 +1,114 @@ +# CSS Selector Fuzzer — Findings + +Run: branch `html-css-fuzz` @ `6ebbcc2fe4`, PHP 8.4.21. ~3600 deterministic +seeds, 0 crashes/timeouts. Three distinct, reproduced WordPress-core correctness +bugs in the new HTML-API CSS selector support. Every selector below is valid, +supported CSS that the API mis-handles **without** reporting lack of support. + +Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '' [--html '']`. + +--- + +## Bug 1 — Identity escapes mis-decode after a multibyte character (mis-parse) + +**Invariant:** `ast-mismatch` (57 hits, the dominant signature). + +`WP_CSS_Selector_Parser_Matcher::consume_escaped_codepoint()` decodes a +non-hex ("identity") escape with: + +```php +$codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); +``` + +`$offset` is a **byte** offset (threaded by reference through the whole +selector-string parse), but `mb_substr()`'s 2nd argument is a **character** +index. The two diverge by one per multibyte continuation byte seen earlier in +the string, so an identity escape preceded by any multibyte content decodes the +**wrong codepoint** (reads N characters too far right, N = preceding continuation bytes). + +Minimal reproduction (second selector's type should be `sup`): + +| selector | parsed context type | +|---|---| +| `#abc,\sup #x` | `sup` ✅ | +| `#Ü,\sup #x` | `uup` ❌ | +| `#ÜÜ,\sup #x` | `pup` ❌ | +| `#ÜÜÜ,\sup #x` | `" up"` ❌ | + +Hex escapes (`\75 `) use byte-correct `substr` and are unaffected; only the +non-hex identity-escape branch is wrong. Depending on what wrong codepoint is +produced this also causes spurious parse failures (a valid selector returns +`null`). + +**Fix direction:** read the next codepoint by byte offset, e.g. +`mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' )`, or decode the UTF-8 +lead byte length from `$input[$offset]` directly. + +--- + +## Bug 2 — Empty-value `^=` `*=` `$=` match everything instead of nothing (mis-match) + +**Invariant:** `match-mismatch-html` / `match-mismatch-tag` (12 hits). + +Per Selectors-4, `[attr^=""]`, `[attr*=""]`, `[attr$=""]` match **nothing** (an +empty operand never matches). `WP_CSS_Attribute_Selector::matches()` instead: + +- `^=`: `substr_compare($attr,'',0,0) === 0` → always 0 → matches any element with the attribute. +- `*=`: `strpos($attr,'') === 0` (PHP) → matches any element with the attribute. +- `$=`: matches elements whose attribute value is exactly `""`. + +`~=` is handled correctly (returns nothing for an empty/whitespace operand). + +Reproduction against ``: + +| selector | WP matches | spec | +|---|---|---| +| `[x^=""]` | `I, B` | none | +| `[x*=""]` | `I, B` | none | +| `[x$=""]` | `I` | none | +| `[x~=""]` | none ✅ | none | + +**Fix direction:** in `matches()`, return `false` for `^= $= *=` (and `~=`) when +`'' === $this->value`, before the `substr_compare`/`strpos` calls. (This also +removes a `substr_compare` negative-length edge with very short attribute values.) + +--- + +## Bug 3 — Off-by-one length guard rejects `[name=x]` at end of string (false reject) + +**Invariant:** `parse-expectation` (1 hit; valid selector → `null`). + +`WP_CSS_Attribute_Selector::parse()` guards "need at least `=x]` remaining": + +```php +// need to match at least `=x]` at this point +if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; +} +``` + +`>=` is off by one: it also rejects the exact-fit case where `=x]` **is** the +remaining tail. This rejects a valid attribute selector that uses the exact-match +`=` operator with a single-character **unquoted** value when its `]` is the last +character of the selector string. + +| selector | result | +|---|---| +| `[a=b]` | `null` ❌ | +| `div.x[y=z]` | `null` ❌ | +| `[a=bb]` | parsed ✅ (2-char value) | +| `[a="b"]` | parsed ✅ (quoted) | +| `[a^=b]` | parsed ✅ (2-char operator) | +| `[a=b].c` | parsed ✅ (trailing content) | + +**Fix direction:** change `>=` to `>` (need `strlen - $updated_offset >= 3`). + +--- + +## Fuzzer status + +Implemented and validated: deterministic seeds, seed-based replay, generative +6-bucket selector generation, independent reference matcher, ~18 invariants, +process-isolated runner, self-check suite. `php tools/css-selector-fuzz/tests/self-check.php` +passes; see `README.md` for usage. No fuzzer-side (oracle/generator) defects +surfaced in 3600 seeds — all failures are the three target bugs above. diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md new file mode 100644 index 0000000000000..0b509450a177e --- /dev/null +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -0,0 +1,178 @@ +# CSS Selector Fuzzer — Next Steps / Improvement Roadmap + +Status: first-generation fuzzer is implemented, validated, and has found three +real WordPress-core bugs (see `FINDINGS.md`). Design and current coverage are in +`README.md`. This document is the prioritized plan to take it from "found three +bugs" to "exhaustive and trustworthy." Do NOT re-explain the existing tool here; +read `README.md` and `FINDINGS.md` first. + +Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch +`html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). +PHP 8.4.21. Everything under `tools/css-selector-fuzz/` is untracked; nothing +committed. `/artifacts` is gitignored (runner output lives there). + +## Measured weaknesses driving this plan + +- Match oracle is a hand-reimplementation (`ReferenceMatcher`) by the same author + who could share a spec misreading with WP — no third opinion exists today. +- Positive-match rate is low (measured): supported-compound 39.6% of parseable + cases match ≥1 element; supported-complex only **14.5%**; ~72% of all supported + cases match nothing. Most match assertions are vacuous `[] == []`. +- The "structurally safe element set" restriction (needed so `model == + parse-tree` holds) means combinator/breadcrumb matching is only ever tested on + clean trees — never on foster-parented / adoption-agency / foreign-content / + implied-end-tag restructured trees, which are the hard cases. +- No metamorphic invariants (the cheapest oracle-free signal class) — absent. +- Line coverage never measured. Some target branches are provably unreachable by + the current generator (e.g. `consume_escaped_codepoint`'s U+FFFD path for + null/surrogate/over-max codepoints; the `normalize_selector_input` NUL→U+FFFD + path). +- No automatic minimizer (the sibling `html-api-fuzz` branch ships + `tools/html-api-fuzz/minimize.php` as a pattern to copy). +- Match path only exercises `WP_HTML_Processor::create_full_parser`; fragment + contexts and varied quirks-mode triggers (only doctype presence is toggled) + are untested. + +## Work items, in priority order + +### 1. Metamorphic invariants (cheapest, highest signal-per-effort, no deps) + +Add oracle-free relations to `Worker::run_case` that must hold for any parseable +supported selector over any document. For each, transform the selector, assert +the match set (both processors) is unchanged — or for AST-level ones, assert the +extracted AST is unchanged: + +- ASCII-case-fold a type-selector name → identical matches (type names are + case-insensitive). +- Reorder subclass selectors within a compound (`div.a#b[c]` ≡ any permutation of + the subclass part) → identical matches and structurally-equivalent AST. +- Escape an arbitrary ident codepoint that does not require escaping → identical + AST and matches (exercises the escape decoder against the no-op case). +- Append a redundant universal (`sel` vs `sel:where`-free `*sel` where the type + slot is empty → `*` + subclasses) → identical matches. +- Duplicate a selector-list branch (`a, a`) → identical matches. +- Whitespace-insert around combinators and commas where insignificant → identical + matches. + +These need no external engine; they would have independently caught Bug 1. + +### 2. Path-directed generation (fix the 14.5% positive-match rate) + +Add a generation mode that GUARANTEES positive matches and meaningful negatives: + +- Pick a random element in the generated model tree. +- Synthesize a selector that must match it: type from its tag, subclasses from a + subset of its real classes/id/attributes, and (for complex) a context chain + drawn from its real ancestor tags with `>`/descendant combinators matching the + actual nesting. +- Emit the matching selector (assert it matches that element) AND near-miss + mutations (swap one ancestor combinator `>`↔descendant, drop/extend a class, + change one attribute operator) and assert the flip. + +This makes the combinator/breadcrumb walker actually exercised with real depth +and real positive/negative boundaries instead of mostly-empty match sets. Keep +the existing buckets; add this as a new bucket. + +### 3. lexbor differential oracle (the match-oracle correctness ceiling) + +Use lexbor as a THIRD, independent oracle — primarily to validate +`ReferenceMatcher`, secondarily to unlock wilder HTML. Build cost is acceptable +(confirmed by maintainer). Refs: https://lexbor.com/modules/selectors/ and the +HTML module for selector matching. + +Design: + +- C harness linking liblexbor: read many `{html, selector}` cases from stdin + (one process, batched — invoked by the runner like the existing PHP worker + subprocess; isolate crashes). Parse HTML with `lxb_html_document_parse`, parse + the selector with the CSS/selectors module, run `lxb_selectors_find`, and via + the callback collect each matched element's unique `data-fid` attribute. Emit + one line of matched-fid sets per case. FFI to `liblexbor.so` is an acceptable + alternative but a standalone CLI isolates crashes better. +- Mark every generated element with a unique `data-fid` (the generator already + does this). +- **Tree-equality gate:** only run the differential on cases where WP's tree and + lexbor's tree agree (compare the fid→tag→breadcrumb sequence from each). This + isolates the SELECTOR layer from HTML tree-construction differences (which are + a different fuzzer's concern — see `html-api-fuzz`). Bonus: this gate lets you + fuzz ARBITRARY/wild HTML and keep any case where the two trees agree, which + relaxes the current "safe element set" restriction and reaches restructured + trees the present generator can't produce. +- Three-way verdict: `reference ≠ lexbor` ⇒ fuzzer-oracle bug (fix the fuzzer); + `reference == lexbor ≠ WP` ⇒ high-confidence WP finding. + +**CRITICAL CAVEAT — quirks-mode / case-sensitivity:** lexbor has a known +class/ID case-sensitivity bug — https://github.com/lexbor/lexbor/issues/368. +WP folds class/ID names ASCII-case-insensitively in QUIRKS mode and +case-sensitively in no-quirks (`WP_HTML_Tag_Processor::is_quirks_mode()`); type +names are always case-insensitive. Do NOT trust lexbor on quirks-mode case +behavior. Restrict the lexbor differential to **no-quirks documents** (emit +``), and keep `ReferenceMatcher` as the authority for the +quirks-mode path. Pin the exact lexbor version used and note whether #368 is +fixed in it. Re-evaluate enabling quirks comparison only after verifying lexbor's +behavior against that issue. + +- Also surface (don't auto-fail) **attribute default case-insensitivity**: + Selectors-4/HTML define a set of attributes matched case-insensitively by + default; WP appears to implement only explicit `i`/`s` modifiers. lexbor may + implement the default set, producing divergence that is either a real WP + conformance gap or an intentional subset limitation — triage per case and + report. + +### 4. Parser-derived oracle tree (decouple "tree right" from "match right") + +Instead of asserting `model == parse-tree`, walk the processor ONCE to capture +the ground-truth tree (fid → tag → breadcrumbs → attributes), then run both the +reference matcher and the lexbor differential against arbitrary/wild HTML using +that captured tree as truth for the selector layer. This is the structural +change that makes #3's wild-HTML mode fully general and lets the generator reuse +`html-api-fuzz`'s nasty-HTML generator. (`model-desync` becomes a separate, +optional sanity check rather than a precondition.) + +### 5. Coverage measurement + reach the unreachable branches + +- Wire line/branch coverage (phpdbg is available: `phpdbg -qrr` with + coverage, or install pcov/xdebug) over the `src/wp-includes/html-api/css/` + classes; gate "done" on a coverage target and a written list of intentionally- + unreached lines. +- Add a generator path emitting raw hex escapes for null / surrogate + (U+D800–U+DFFF) / over-max (> U+10FFFF) codepoints and assert they decode to + U+FFFD (currently unreachable — the renderer only escapes real codepoints). +- Fuzz NUL bytes and CR/FF in the selector INPUT to exercise + `normalize_selector_input` (NUL→U+FFFD, CR/CRLF/FF→LF). + +### 6. Automatic minimizer + +Port the delta-debugging pattern from `tools/html-api-fuzz/minimize.php`: given a +failing seed, shrink both the HTML and the selector (byte/structural deletes, +keep-failing) to a minimal reproducer. Wire into `replay.php` or a new +`minimize.php`. Bugs 1 and 3 were hand-minimized; automate it. + +### 7. Broaden match surface + +- Run the match oracle through `create_fragment` with varied fragment contexts, + not just `create_full_parser`. +- Vary all quirks-mode triggers (not only doctype presence): no-doctype, + malformed doctype, ``, limited-quirks doctypes. + +## Acceptance bar for "exacting standards" + +- Coverage measured and reported for the `css/` classes, with a justified list of + any unreached lines. +- Three independent oracles agree on no-quirks supported cases (AST round-trip, + `ReferenceMatcher`, lexbor); divergences are triaged to either a WP finding or + a fuzzer-oracle fix — never left ambiguous. +- Metamorphic invariants in place and passing. +- Positive-match rate for combinator selectors materially raised (path-directed + generation); match assertions are mostly non-vacuous. +- Minimizer produces minimal repros automatically. +- A clean multi-thousand-seed run with all signatures triaged; `FINDINGS.md` + updated with any new bugs (each with a minimal repro and a one-line fix + direction), and confirmation that the three known bugs still reproduce. + +## Existing bugs to keep verifying (regression anchors) + +From `FINDINGS.md` — minimal repros, all must still trigger until core is fixed: +1. Identity escape after multibyte mis-decodes: `#Ü,\sup #x` → type `uup` (want `sup`). +2. Empty-value matchers match everything: `[x^=""]`, `[x*=""]`, `[x$=""]`. +3. Off-by-one length guard: `[a=b]` (single-char unquoted value, exact `=`, at EOF) → `null`. diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md new file mode 100644 index 0000000000000..03c2c51d654f7 --- /dev/null +++ b/tools/css-selector-fuzz/README.md @@ -0,0 +1,70 @@ +# CSS Selector Fuzzer + +Generative fuzzer for the HTML API CSS selector support: +`WP_CSS_Compound_Selector_List`, `WP_CSS_Complex_Selector_List`, and the +`select()` methods on `WP_HTML_Tag_Processor` and `WP_HTML_Processor`. + +Every case is fully deterministic from its integer seed: the same seed always +produces the same document, the same selector, and the same verdict. + +## What a case does + +1. Generate a random HTML document from a structurally "safe" element set so + the model tree is provably identical to the parsed tree (this is itself + verified every case — `model-desync`). +2. Generate a selector in one of six buckets: + - `supported-compound` — must parse in both grammars; carries intended AST. + - `supported-complex` — uses `>`/descendant combinators; must parse only + in the complex grammar; carries intended AST. + - `unsupported` — valid CSS the API intentionally rejects (pseudo-classes + and -elements, `+`/`~`/`||` combinators, namespaces, non-type context + selectors); must not parse. + - `invalid` — not valid CSS; must not parse. + - `chaos` — arbitrary bytes; no parse expectation. + - `mutated` — a supported selector with random byte mutations; no parse + expectation. +3. Check invariants: + - No PHP error/warning/exception from parsing or matching, ever. + - Parse result (instance vs `null`) matches the bucket's expectation. + - Anything the compound grammar parses, the complex grammar parses, and + both produce the same AST. + - Parsed AST equals the generated AST (escapes, strings, whitespace and + case randomization must not change meaning). + - For any selector that parses (including chaos/mutated), the `select()` + match set equals an independent spec-faithful reference matcher, on both + processors, including quirks-mode class/ID case-insensitivity. + - For any selector that does not parse, `select()` returns `false`, + `_doing_it_wrong` fires exactly once per call (also via the parse + cache), and the processor remains usable. + - The processor ends with no `get_last_error()`/unsupported state. + - Repeating a case yields a byte-identical result digest (determinism). + +## Usage + +Bounded fuzz run (process-isolated chunks, crash/hang attribution): + + php tools/css-selector-fuzz/runner.php --max-seeds 1000 --duration-seconds 60 + +Artifacts go to `artifacts/css-selector-fuzz/run-*/` and are intentionally +small: `state.json` (counters, per-signature tallies) and `failures.ndjson` +(one line per failure, with base64 selector + document for offline analysis). + +Replay a failure by seed: + + php tools/css-selector-fuzz/replay.php --seed 42 --show-html + php tools/css-selector-fuzz/replay.php --seed 42 --json + +Probe a specific selector: + + php tools/css-selector-fuzz/replay.php --selector 'section > div.cls' --html '
' + +Run a batch in-process (no isolation, faster): + + php tools/css-selector-fuzz/worker.php --start-seed 1 --count 500 + +Options of note: + +- `runner.php --stop-on-failure` stops at the first failing chunk. +- `worker.php --determinism-every N` re-runs every Nth seed twice (default 16). +- `worker.php --max-failures N` stops a batch after N failures (default 200) + to bound artifact size. diff --git a/tools/css-selector-fuzz/lib/AstExtractor.php b/tools/css-selector-fuzz/lib/AstExtractor.php new file mode 100644 index 0000000000000..ed81beac30c3e --- /dev/null +++ b/tools/css-selector-fuzz/lib/AstExtractor.php @@ -0,0 +1,149 @@ + array(), + 'self' => self::from_compound( $selector ), + ); + } + return $out; + } + + private static function from_complex( \WP_CSS_Complex_Selector $selector ): array { + $context = array(); + foreach ( (array) $selector->context_selectors as $pair ) { + if ( ! is_array( $pair ) || 2 !== count( $pair ) ) { + throw new \UnexpectedValueException( 'Context selector pair has unexpected shape.' ); + } + if ( ! $pair[0] instanceof \WP_CSS_Type_Selector ) { + throw new \UnexpectedValueException( 'Context selector is not a type selector: ' . self::describe( $pair[0] ) ); + } + if ( ! in_array( $pair[1], array( ' ', '>' ), true ) ) { + throw new \UnexpectedValueException( 'Context selector uses unsupported combinator: ' . var_export( $pair[1], true ) ); + } + $context[] = array( $pair[0]->type, $pair[1] ); + } + + return array( + 'context' => $context, + 'self' => self::from_compound( $selector->self_selector ), + ); + } + + private static function from_compound( \WP_CSS_Compound_Selector $selector ): array { + $subs = null; + if ( null !== $selector->subclass_selectors ) { + if ( array() === $selector->subclass_selectors ) { + throw new \UnexpectedValueException( 'Compound selector has empty (non-null) subclass selector array.' ); + } + $subs = array(); + foreach ( $selector->subclass_selectors as $sub ) { + $subs[] = self::from_subclass( $sub ); + } + } + + if ( null === $selector->type_selector && null === $subs ) { + throw new \UnexpectedValueException( 'Compound selector has neither type nor subclass selectors.' ); + } + + return array( + 'type' => null === $selector->type_selector ? null : $selector->type_selector->type, + 'subs' => $subs, + ); + } + + private static function from_subclass( $sub ): array { + if ( $sub instanceof \WP_CSS_Class_Selector ) { + return array( + 'kind' => 'class', + 'name' => $sub->class_name, + ); + } + if ( $sub instanceof \WP_CSS_ID_Selector ) { + return array( + 'kind' => 'id', + 'name' => $sub->id, + ); + } + if ( $sub instanceof \WP_CSS_Attribute_Selector ) { + $valid_matchers = array( + null, + \WP_CSS_Attribute_Selector::MATCH_EXACT, + \WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, + \WP_CSS_Attribute_Selector::MATCH_EXACT_OR_HYPHEN_SUFFIXED, + \WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, + \WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, + \WP_CSS_Attribute_Selector::MATCH_CONTAINS, + ); + if ( ! in_array( $sub->matcher, $valid_matchers, true ) ) { + throw new \UnexpectedValueException( 'Attribute selector has unknown matcher: ' . var_export( $sub->matcher, true ) ); + } + $valid_modifiers = array( + null, + \WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, + \WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, + ); + if ( ! in_array( $sub->modifier, $valid_modifiers, true ) ) { + throw new \UnexpectedValueException( 'Attribute selector has unknown modifier: ' . var_export( $sub->modifier, true ) ); + } + if ( ( null === $sub->matcher ) !== ( null === $sub->value ) ) { + throw new \UnexpectedValueException( 'Attribute selector matcher/value nullness mismatch.' ); + } + return array( + 'kind' => 'attr', + 'name' => $sub->name, + 'matcher' => $sub->matcher, + 'value' => $sub->value, + 'modifier' => $sub->modifier, + ); + } + throw new \UnexpectedValueException( 'Unknown subclass selector: ' . self::describe( $sub ) ); + } + + private static function get_private( $object, string $property, string $declaring_class ) { + $reflection = new \ReflectionProperty( $declaring_class, $property ); + $reflection->setAccessible( true ); + $value = $reflection->getValue( $object ); + if ( ! is_array( $value ) ) { + throw new \UnexpectedValueException( "Property {$property} is not an array." ); + } + return $value; + } + + private static function describe( $value ): string { + return is_object( $value ) ? get_class( $value ) : gettype( $value ); + } +} diff --git a/tools/css-selector-fuzz/lib/Bootstrap.php b/tools/css-selector-fuzz/lib/Bootstrap.php new file mode 100644 index 0000000000000..6d33b4de7c4e9 --- /dev/null +++ b/tools/css-selector-fuzz/lib/Bootstrap.php @@ -0,0 +1,63 @@ + */ + public static function doing_it_wrong_calls(): array { + return $GLOBALS['css_selector_fuzz_doing_it_wrong']; + } +} diff --git a/tools/css-selector-fuzz/lib/DocumentGenerator.php b/tools/css-selector-fuzz/lib/DocumentGenerator.php new file mode 100644 index 0000000000000..70e3ab80d2a39 --- /dev/null +++ b/tools/css-selector-fuzz/lib/DocumentGenerator.php @@ -0,0 +1,450 @@ +prng = $prng; + $this->max_elements = $max_elements; + $this->pools = array( + 'tags' => array(), + 'classes' => array(), + 'ids' => array(), + 'attrNames' => array(), + 'attrValues' => array(), + ); + } + + /** + * @return array{model: array, html: string, quirks: bool, pools: array} + */ + public static function generate( Prng $prng ): array { + $generator = new self( $prng, $prng->int( 8, 40 ) ); + return $generator->build(); + } + + private function build(): array { + $has_doctype = $this->prng->chance( 85 ); + + $head_children = array(); + if ( $this->prng->chance( 60 ) ) { + $head_children[] = $this->make_element( 'title', array(), array() ); + } + if ( $this->prng->chance( 30 ) ) { + $head_children[] = $this->make_element( 'meta', $this->random_attrs(), array() ); + } + + $body_children = array(); + $child_budget = $this->prng->int( 1, 6 ); + for ( $i = 0; $i < $child_budget && $this->element_count < $this->max_elements; $i++ ) { + $body_children[] = $this->random_subtree( 0 ); + } + + $head = $this->make_element( 'head', array(), $head_children ); + $body = $this->make_element( 'body', $this->prng->chance( 30 ) ? $this->random_attrs() : array(), $body_children ); + $html = $this->make_element( 'html', $this->prng->chance( 20 ) ? $this->random_attrs() : array(), array( $head, $body ) ); + + $rendered = ( $has_doctype ? '' : '' ) . $this->render_element( $html ); + + foreach ( $this->pools as $key => $values ) { + $this->pools[ $key ] = array_values( array_unique( $values ) ); + } + + return array( + 'model' => $html, + 'html' => $rendered, + 'quirks' => ! $has_doctype, + 'pools' => $this->pools, + ); + } + + private function random_subtree( int $depth ): array { + ++$this->element_count; + + if ( $depth >= 7 || $this->element_count >= $this->max_elements || $this->prng->chance( 25 ) ) { + // Leaf. + if ( $this->prng->chance( 25 ) ) { + return $this->make_element( $this->prng->choice( self::VOID_TAGS ), $this->random_attrs(), array(), true ); + } + return $this->make_element( $this->prng->choice( self::SAFE_TAGS ), $this->random_attrs(), array() ); + } + + $children = array(); + $child_count = $this->prng->int( 1, 4 ); + for ( $i = 0; $i < $child_count && $this->element_count < $this->max_elements; $i++ ) { + $children[] = $this->random_subtree( $depth + 1 ); + } + + return $this->make_element( $this->prng->choice( self::SAFE_TAGS ), $this->random_attrs(), $children ); + } + + private function make_element( string $tag, array $attrs, array $children, bool $is_void = false ): array { + $fid = 'e' . $this->fid_counter++; + + $written_tag = $this->prng->chance( 15 ) ? $this->random_case( $tag ) : $tag; + + $this->pools['tags'][] = $tag; + + return array( + 'tag' => $written_tag, + 'fid' => $fid, + 'attrs' => $attrs, + 'children' => $children, + 'void' => $is_void || in_array( strtolower( $tag ), array( 'meta', 'br', 'hr', 'img', 'wbr', 'input', 'embed' ), true ), + ); + } + + /** @return array name/value pairs in source order. */ + private function random_attrs(): array { + $attrs = array(); + $count = $this->prng->weighted( + array( + 0 => 15, + 1 => 30, + 2 => 30, + 3 => 15, + 4 => 10, + ) + ); + + $used_names = array(); + for ( $i = 0; $i < $count; $i++ ) { + $name = $this->prng->choice( self::ATTR_NAMES ); + + // Occasionally repeat an attribute name: the processor keeps the first. + $is_duplicate = isset( $used_names[ ascii_strtolower( $name ) ] ); + if ( $is_duplicate && ! $this->prng->chance( 20 ) ) { + continue; + } + $used_names[ ascii_strtolower( $name ) ] = true; + + if ( $this->prng->chance( 12 ) ) { + $name = $this->random_case( $name ); + } + + $lower = ascii_strtolower( $name ); + if ( 'class' === $lower ) { + $value = $this->random_class_value(); + } elseif ( 'id' === $lower ) { + $value = $this->prng->chance( 85 ) ? $this->random_id_value() : ( $this->prng->chance( 50 ) ? '' : true ); + } elseif ( in_array( $lower, array( 'disabled', 'hidden' ), true ) ) { + $value = $this->prng->chance( 70 ) ? true : $this->prng->choice( array( '', 'disabled', 'true' ) ); + } else { + $value = $this->prng->chance( 12 ) ? true : $this->random_attr_value(); + } + + $this->pools['attrNames'][] = ascii_strtolower( $name ); + if ( is_string( $value ) ) { + $this->pools['attrValues'][] = $value; + } + + $attrs[] = array( $name, $value ); + } + + return $attrs; + } + + private function random_class_value(): string { + $count = $this->prng->int( 1, 4 ); + $classes = array(); + for ( $i = 0; $i < $count; $i++ ) { + $class = $this->random_word( true ); + $classes[] = $class; + $this->pools['classes'][] = $class; + } + + $ws = array( ' ', ' ', ' ', "\t", "\n", "\f", ' ' ); + $value = $this->prng->chance( 20 ) ? $this->prng->choice( $ws ) : ''; + foreach ( $classes as $i => $class ) { + if ( $i > 0 ) { + $value .= $this->prng->choice( $ws ); + } + $value .= $class; + } + if ( $this->prng->chance( 20 ) ) { + $value .= $this->prng->choice( $ws ); + } + return $value; + } + + private function random_id_value(): string { + $id = $this->random_word( true ); + $this->pools['ids'][] = $id; + return $id; + } + + private function random_attr_value(): string { + $kind = $this->prng->weighted( + array( + 'word' => 35, + 'words' => 20, + 'hyphenated' => 15, + 'empty' => 8, + 'spicy' => 12, + 'unicode' => 10, + ) + ); + + switch ( $kind ) { + case 'word': + return $this->random_word( true ); + case 'words': + $parts = array(); + $n = $this->prng->int( 2, 4 ); + for ( $i = 0; $i < $n; $i++ ) { + $parts[] = $this->random_word( true ); + } + return implode( $this->prng->choice( array( ' ', ' ', "\t", "\n" ) ), $parts ); + case 'hyphenated': + return $this->random_word( false ) . '-' . $this->random_word( false ); + case 'empty': + return ''; + case 'spicy': + $spice = array( 'a"b', "a'b", 'a&b', 'ab', 'a=b', 'a b c', '&', '"x', '100%', 'semi;colon', 'a,b' ); + return $this->prng->choice( $spice ); + case 'unicode': + $unicode = array( 'héllo', 'ÄÖÜ', '✓done', 'naïve', 'Ωmega', '\u{1F600}smile' ); + $value = $this->prng->choice( $unicode ); + return str_replace( '\u{1F600}', "\u{1F600}", $value ); + } + return 'fallback'; + } + + private function random_word( bool $allow_mixed_case ): string { + $stems = array( 'alpha', 'beta', 'gamma', 'delta', 'box', 'col', 'item', 'note', 'wide', 'main-item', 'x', 'a', '-lead', '--var', '_under', 'Über', 'mixedCase' ); + $word = $this->prng->choice( $stems ); + if ( $this->prng->chance( 30 ) ) { + $word .= (string) $this->prng->int( 0, 99 ); + } + if ( $allow_mixed_case && $this->prng->chance( 15 ) ) { + $word = $this->random_case( $word ); + } + return $word; + } + + private function random_case( string $input ): string { + $out = ''; + for ( $i = 0; $i < strlen( $input ); $i++ ) { + $c = $input[ $i ]; + $out .= $this->prng->chance( 50 ) ? strtoupper( $c ) : strtolower( $c ); + } + return $out; + } + + /* + * --------- + * Rendering + * --------- + */ + + private function render_element( array $element ): string { + $out = '<' . $element['tag']; + + $rendered_attrs = array( ' data-fid="' . $element['fid'] . '"' ); + foreach ( $element['attrs'] as $attr ) { + $rendered_attrs[] = ' ' . $this->render_attr( $attr[0], $attr[1] ); + } + $out .= implode( '', $rendered_attrs ); + + if ( $element['void'] ) { + $out .= $this->prng->chance( 25 ) ? ' />' : '>'; + return $out; + } + + $out .= '>'; + + $child_bits = array(); + foreach ( $element['children'] as $child ) { + $child_bits[] = $this->render_element( $child ); + } + + /* + * Sprinkle text and comments between children — but never directly + * inside `html` or `head`, where character tokens would trigger + * insertion-mode changes (early body creation, head popping) that + * desynchronize the model from the parsed tree. + */ + $lower_tag = strtolower( $element['tag'] ); + $may_have_filler = ! in_array( $lower_tag, array( 'html', 'head' ), true ); + $filler_options = array( + '', + 'text', + ' more text ', + "\n ", + '& <escaped>', + '', + 'café ✓', + ); + $content = ''; + foreach ( $child_bits as $bit ) { + if ( $may_have_filler && $this->prng->chance( 40 ) ) { + $content .= $this->prng->choice( $filler_options ); + } + $content .= $bit; + } + if ( $may_have_filler && $this->prng->chance( 40 ) ) { + $content .= $this->prng->choice( $filler_options ); + } + if ( 'title' === $lower_tag ) { + // RAWTEXT: keep it plain. + $content = $this->prng->chance( 60 ) ? 'Fuzz Title' : ''; + } + + return $out . $content . ''; + } + + /** @param string|true $value */ + private function render_attr( string $name, $value ): string { + if ( true === $value ) { + return $name; + } + + $style = $this->prng->weighted( + array( + 'double' => 60, + 'single' => 20, + 'unquoted' => 20, + ) + ); + + if ( 'unquoted' === $style && ( '' === $value || strlen( $value ) !== strspn( $value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-' ) ) ) { + $style = 'double'; + } + + switch ( $style ) { + case 'unquoted': + return $name . '=' . $value; + case 'single': + return $name . "='" . str_replace( array( '&', "'", '<' ), array( '&', ''', '<' ), $value ) . "'"; + default: + return $name . '="' . str_replace( array( '&', '"', '<' ), array( '&', '"', '<' ), $value ) . '"'; + } + } + + /* + * ---------------- + * Model utilities + * ---------------- + */ + + /** Pre-order (document order) list of elements. */ + public static function flatten( array $element ): array { + $out = array( $element ); + foreach ( $element['children'] as $child ) { + foreach ( self::flatten( $child ) as $descendant ) { + $out[] = $descendant; + } + } + return $out; + } + + /** + * Pre-order list of ( element, ancestors ) pairs where ancestors is the + * chain from nearest ancestor to root — the same orientation as + * WP_HTML_Processor::get_breadcrumbs() reversed past self. + */ + public static function flatten_with_ancestors( array $element, array $ancestors = array() ): array { + $out = array( array( $element, $ancestors ) ); + $next_ancestors = array_merge( array( $element ), $ancestors ); + foreach ( $element['children'] as $child ) { + foreach ( self::flatten_with_ancestors( $child, $next_ancestors ) as $pair ) { + $out[] = $pair; + } + } + return $out; + } + + /** First attribute value for a name, ASCII case-insensitive; null if absent. */ + public static function get_attribute_value( array $element, string $name ) { + $comparable = ascii_strtolower( $name ); + foreach ( $element['attrs'] as $attr ) { + if ( ascii_strtolower( $attr[0] ) === $comparable ) { + return $attr[1]; + } + } + return null; + } +} diff --git a/tools/css-selector-fuzz/lib/Prng.php b/tools/css-selector-fuzz/lib/Prng.php new file mode 100644 index 0000000000000..b8d8737304277 --- /dev/null +++ b/tools/css-selector-fuzz/lib/Prng.php @@ -0,0 +1,65 @@ +key = $seed . "\x1f" . $label; + } + + /** Derives an independent child stream; consuming it does not affect this stream. */ + public function fork( string $label ): Prng { + return new Prng( $this->key, $label . ':' . $this->uint32() ); + } + + public function bytes( int $length ): string { + while ( strlen( $this->buffer ) < $length ) { + $this->buffer .= hash( 'sha256', $this->key . ':' . $this->counter++, true ); + } + $out = substr( $this->buffer, 0, $length ); + $this->buffer = substr( $this->buffer, $length ); + return $out; + } + + public function uint32(): int { + $parts = unpack( 'Nvalue', $this->bytes( 4 ) ); + return (int) $parts['value']; + } + + public function int( int $min, int $max ): int { + if ( $max <= $min ) { + return $min; + } + return $min + ( $this->uint32() % ( $max - $min + 1 ) ); + } + + public function chance( int $numerator, int $denominator = 100 ): bool { + return $this->int( 1, $denominator ) <= $numerator; + } + + public function choice( array $values ) { + return $values[ $this->int( 0, count( $values ) - 1 ) ]; + } + + /** @param array $weights value => weight */ + public function weighted( array $weights ) { + $total = array_sum( $weights ); + $pick = $this->int( 1, max( 1, (int) $total ) ); + foreach ( $weights as $value => $weight ) { + $pick -= $weight; + if ( $pick <= 0 ) { + return $value; + } + } + return array_key_first( $weights ); + } +} diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php new file mode 100644 index 0000000000000..fd834a543041d --- /dev/null +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -0,0 +1,253 @@ +' === $combinator ) { + return self::type_matches( $type, $ancestor_tags[0] ) + && self::explore_context( $rest, array_slice( $ancestor_tags, 1 ) ); + } + + // Descendant: try every matching ancestor. + $count = count( $ancestor_tags ); + for ( $i = 0; $i < $count; $i++ ) { + if ( + self::type_matches( $type, $ancestor_tags[ $i ] ) && + self::explore_context( $rest, array_slice( $ancestor_tags, $i + 1 ) ) + ) { + return true; + } + } + return false; + } + + public static function compound_matches( array $compound, array $element, bool $quirks ): bool { + if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $element['tag'] ) ) { + return false; + } + foreach ( (array) $compound['subs'] as $sub ) { + if ( ! self::sub_matches( $sub, $element, $quirks ) ) { + return false; + } + } + return true; + } + + private static function type_matches( string $type, string $tag ): bool { + return '*' === $type || ascii_strtolower( $type ) === ascii_strtolower( $tag ); + } + + private static function sub_matches( array $sub, array $element, bool $quirks ): bool { + switch ( $sub['kind'] ) { + case 'class': + return self::class_matches( $sub['name'], $element, $quirks ); + case 'id': + return self::id_matches( $sub['name'], $element, $quirks ); + case 'attr': + return self::attr_matches( $sub, $element ); + } + return false; + } + + private static function class_matches( string $wanted, array $element, bool $quirks ): bool { + $class_value = DocumentGenerator::get_attribute_value( $element, 'class' ); + if ( ! is_string( $class_value ) ) { + return false; + } + + $length = strlen( $class_value ); + $at = 0; + while ( $at < $length ) { + $at += strspn( $class_value, self::WHITESPACE, $at ); + if ( $at >= $length ) { + break; + } + $word_length = strcspn( $class_value, self::WHITESPACE, $at ); + $word = substr( $class_value, $at, $word_length ); + $at += $word_length; + + if ( + $quirks + ? ascii_strtolower( $word ) === ascii_strtolower( $wanted ) + : $word === $wanted + ) { + return true; + } + } + return false; + } + + private static function id_matches( string $wanted, array $element, bool $quirks ): bool { + $id = DocumentGenerator::get_attribute_value( $element, 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + return $quirks + ? ascii_strtolower( $id ) === ascii_strtolower( $wanted ) + : $id === $wanted; + } + + private static function attr_matches( array $sub, array $element ): bool { + $attr_value = DocumentGenerator::get_attribute_value( $element, $sub['name'] ); + if ( null === $attr_value ) { + return false; + } + if ( null === $sub['matcher'] ) { + return true; + } + if ( true === $attr_value ) { + $attr_value = ''; + } + + $wanted = (string) $sub['value']; + $case_insensitive = 'case-insensitive' === $sub['modifier']; + if ( $case_insensitive ) { + $attr_value = ascii_strtolower( $attr_value ); + $wanted = ascii_strtolower( $wanted ); + } + + switch ( $sub['matcher'] ) { + case 'exact': + return $attr_value === $wanted; + + case 'one-of': + if ( '' === $wanted || strlen( $wanted ) !== strcspn( $wanted, self::WHITESPACE ) ) { + return false; + } + $length = strlen( $attr_value ); + $at = 0; + while ( $at < $length ) { + $at += strspn( $attr_value, self::WHITESPACE, $at ); + if ( $at >= $length ) { + break; + } + $word_length = strcspn( $attr_value, self::WHITESPACE, $at ); + if ( substr( $attr_value, $at, $word_length ) === $wanted ) { + return true; + } + $at += $word_length; + } + return false; + + case 'exact-or-hyphen-suffixed': + if ( $attr_value === $wanted ) { + return true; + } + return 0 === strncmp( $attr_value, $wanted . '-', strlen( $wanted ) + 1 ); + + case 'prefixed': + if ( '' === $wanted ) { + return false; + } + return 0 === strncmp( $attr_value, $wanted, strlen( $wanted ) ); + + case 'suffixed': + if ( '' === $wanted ) { + return false; + } + return strlen( $attr_value ) >= strlen( $wanted ) + && substr( $attr_value, -strlen( $wanted ) ) === $wanted; + + case 'contains': + if ( '' === $wanted ) { + return false; + } + return false !== strpos( $attr_value, $wanted ); + } + + return false; + } +} diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php new file mode 100644 index 0000000000000..53347c083e79b --- /dev/null +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -0,0 +1,899 @@ +prng = $prng; + $this->pools = $pools; + } + + /** + * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). + * @return array{ + * bucket: string, + * selector: string, + * expectCompound: bool|null, + * expectComplex: bool|null, + * ast: array|null, + * } + */ + public static function generate( Prng $prng, array $pools, ?string $bucket = null ): array { + $generator = new self( $prng, $pools ); + + if ( null === $bucket ) { + $bucket = $prng->weighted( + array( + 'supported-compound' => 30, + 'supported-complex' => 25, + 'unsupported' => 15, + 'invalid' => 12, + 'chaos' => 8, + 'mutated' => 10, + ) + ); + } + + switch ( $bucket ) { + case 'supported-compound': + $ast = $generator->gen_complex_list( false ); + return array( + 'bucket' => $bucket, + 'selector' => $generator->render_complex_list( $ast ), + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => $ast, + ); + + case 'supported-complex': + $ast = $generator->gen_complex_list( true ); + return array( + 'bucket' => $bucket, + 'selector' => $generator->render_complex_list( $ast ), + 'expectCompound' => false, + 'expectComplex' => true, + 'ast' => $ast, + ); + + case 'unsupported': + return array( + 'bucket' => $bucket, + 'selector' => $generator->gen_unsupported(), + 'expectCompound' => false, + 'expectComplex' => false, + 'ast' => null, + ); + + case 'invalid': + return array( + 'bucket' => $bucket, + 'selector' => $generator->gen_invalid(), + 'expectCompound' => false, + 'expectComplex' => false, + 'ast' => null, + ); + + case 'chaos': + return array( + 'bucket' => $bucket, + 'selector' => $generator->gen_chaos(), + 'expectCompound' => null, + 'expectComplex' => null, + 'ast' => null, + ); + + case 'mutated': + default: + $ast = $generator->gen_complex_list( $generator->prng->chance( 50 ) ); + $rendered = $generator->render_complex_list( $ast ); + return array( + 'bucket' => 'mutated', + 'selector' => $generator->mutate( $rendered ), + 'expectCompound' => null, + 'expectComplex' => null, + 'ast' => null, + ); + } + } + + /* + * -------------- + * AST generation + * -------------- + * + * Canonical AST shapes (matching what AstExtractor produces from + * parsed WP_CSS_* objects): + * + * list: array of complex + * complex: array( 'context' => array( array( type, combinator ) ... right-to-left ), 'self' => compound ) + * compound: array( 'type' => string|null, 'subs' => array|null ) + * sub: array( 'kind' => 'class'|'id', 'name' => string ) + * | array( 'kind' => 'attr', 'name' => string, 'matcher' => string|null, + * 'value' => string|null, 'modifier' => string|null ) + */ + + private function gen_complex_list( bool $require_combinator ): array { + $count = $this->prng->weighted( + array( + 1 => 55, + 2 => 30, + 3 => 15, + ) + ); + + $list = array(); + $combinator_at = $require_combinator ? $this->prng->int( 0, $count - 1 ) : -1; + for ( $i = 0; $i < $count; $i++ ) { + $wants_combinators = $i === $combinator_at || ( $require_combinator && $this->prng->chance( 30 ) ); + $list[] = $this->gen_complex( $require_combinator ? $wants_combinators : false ); + } + return $list; + } + + private function gen_complex( bool $with_combinators ): array { + $context = array(); + if ( $with_combinators ) { + $context_count = $this->prng->int( 1, 3 ); + for ( $i = 0; $i < $context_count; $i++ ) { + $context[] = array( + $this->gen_type_name( true ), + $this->prng->chance( 50 ) ? ' ' : '>', + ); + } + } + + return array( + 'context' => $context, + 'self' => $this->gen_compound(), + ); + } + + private function gen_compound(): array { + $has_type = $this->prng->chance( 65 ); + $sub_count = $this->prng->weighted( + array( + 0 => 30, + 1 => 40, + 2 => 20, + 3 => 10, + ) + ); + if ( ! $has_type && 0 === $sub_count ) { + if ( $this->prng->chance( 50 ) ) { + $has_type = true; + } else { + $sub_count = 1; + } + } + + $subs = array(); + for ( $i = 0; $i < $sub_count; $i++ ) { + $subs[] = $this->gen_subclass(); + } + + return array( + 'type' => $has_type ? $this->gen_type_name( false ) : null, + 'subs' => array() === $subs ? null : $subs, + ); + } + + private function gen_type_name( bool $for_context ): string { + if ( $this->prng->chance( $for_context ? 25 : 12 ) ) { + return '*'; + } + $pool = $this->pools['tags'] ?? array(); + if ( array() !== $pool && $this->prng->chance( 70 ) ) { + $name = $this->prng->choice( $pool ); + return $this->prng->chance( 25 ) ? $this->random_case( $name ) : $name; + } + return $this->prng->choice( array( 'video', 'table', 'x-absent', 'object', 'span' ) ); + } + + private function gen_subclass(): array { + $kind = $this->prng->weighted( + array( + 'class' => 40, + 'id' => 25, + 'attr' => 35, + ) + ); + + switch ( $kind ) { + case 'class': + return array( + 'kind' => 'class', + 'name' => $this->pick_name( 'classes' ), + ); + case 'id': + return array( + 'kind' => 'id', + 'name' => $this->pick_name( 'ids' ), + ); + default: + return $this->gen_attr_selector(); + } + } + + private function gen_attr_selector(): array { + $name = $this->pick_name( 'attrNames' ); + + $matcher = $this->prng->weighted( + array( + '' => 25, + 'exact' => 20, + 'one-of' => 12, + 'exact-or-hyphen-suffixed' => 11, + 'prefixed' => 11, + 'suffixed' => 11, + 'contains' => 10, + ) + ); + $matcher = '' === $matcher ? null : $matcher; + + if ( null === $matcher ) { + return array( + 'kind' => 'attr', + 'name' => $name, + 'matcher' => null, + 'value' => null, + 'modifier' => null, + ); + } + + $modifier = $this->prng->weighted( + array( + '' => 70, + 'case-insensitive' => 18, + 'case-sensitive' => 12, + ) + ); + + return array( + 'kind' => 'attr', + 'name' => $name, + 'matcher' => $matcher, + 'value' => $this->gen_attr_value(), + 'modifier' => '' === $modifier ? null : $modifier, + ); + } + + private function gen_attr_value(): string { + $pool = $this->pools['attrValues'] ?? array(); + + $kind = $this->prng->weighted( + array( + 'pool' => 35, + 'pool-part' => 20, + 'pool-case' => 10, + 'empty' => 10, + 'word' => 15, + 'tricky' => 10, + ) + ); + + if ( in_array( $kind, array( 'pool', 'pool-part', 'pool-case' ), true ) && array() === $pool ) { + $kind = 'word'; + } + + switch ( $kind ) { + case 'pool': + return $this->prng->choice( $pool ); + + case 'pool-part': + $value = $this->prng->choice( $pool ); + if ( '' === $value ) { + return ''; + } + $points = utf8_codepoints( $value ); + $total = count( $points ); + $start = $this->prng->int( 0, max( 0, $total - 1 ) ); + $length = $this->prng->int( 1, $total - $start ); + $part = ''; + for ( $i = $start; $i < $start + $length; $i++ ) { + $part .= $points[ $i ][0]; + } + return $part; + + case 'pool-case': + return $this->random_case( $this->prng->choice( $pool ) ); + + case 'empty': + return ''; + + case 'word': + return $this->prng->choice( array( 'alpha', 'beta9', 'value', 'main-item', 'Z', 'i', 's', 'one two', 'x-y-z' ) ); + + case 'tricky': + default: + return $this->prng->choice( + array( + 'a b', + " lead", + "trail ", + "tab\there", + "line\nbreak", + 'quote"inside', + "apos'inside", + 'back\\slash', + '-', + '--', + '0digit', + 'ünïcode', + ) + ); + } + } + + private function pick_name( string $pool_key ): string { + $pool = $this->pools[ $pool_key ] ?? array(); + if ( array() !== $pool && $this->prng->chance( 65 ) ) { + $name = $this->prng->choice( $pool ); + if ( '' !== $name && $this->prng->chance( 20 ) ) { + $name = $this->random_case( $name ); + } + if ( '' !== $name ) { + return $name; + } + } + return $this->prng->choice( + array( + 'absent', + 'no-such-thing', + 'x', + '-lead', + '--double', + '_under', + 'Ünïcode', + 'with space', + '9starts-with-digit', + '-9hyphen-digit', + 'mixedCase', + ) + ); + } + + /* + * --------- + * Rendering + * --------- + */ + + private function render_complex_list( array $list ): string { + $bits = array(); + foreach ( $list as $complex ) { + $bits[] = $this->render_complex( $complex ); + } + + $out = $this->maybe_ws( 25 ); + foreach ( $bits as $i => $bit ) { + if ( $i > 0 ) { + $out .= $this->maybe_ws( 40 ) . ',' . $this->maybe_ws( 60 ); + } + $out .= $bit; + } + return $out . $this->maybe_ws( 25 ); + } + + private function render_complex( array $complex ): string { + $out = ''; + // Context selectors are stored right-to-left; render left-to-right. + $reversed = array_reverse( $complex['context'] ); + foreach ( $reversed as $pair ) { + list( $type, $combinator ) = $pair; + $out .= '*' === $type ? '*' : $this->render_ident( $type ); + if ( '>' === $combinator ) { + $out .= $this->maybe_ws( 50 ) . '>' . $this->maybe_ws( 50 ); + } else { + $out .= $this->ws(); + } + } + return $out . $this->render_compound( $complex['self'] ); + } + + private function render_compound( array $compound ): string { + $out = ''; + if ( null !== $compound['type'] ) { + $out .= '*' === $compound['type'] ? '*' : $this->render_ident( $compound['type'] ); + } + foreach ( (array) $compound['subs'] as $sub ) { + switch ( $sub['kind'] ) { + case 'class': + $out .= '.' . $this->render_ident( $sub['name'] ); + break; + case 'id': + $out .= '#' . $this->render_ident( $sub['name'] ); + break; + case 'attr': + $out .= $this->render_attr_selector( $sub ); + break; + } + } + return $out; + } + + private function render_attr_selector( array $sub ): string { + $out = '[' . $this->maybe_ws( 20 ) . $this->render_ident( $sub['name'] ) . $this->maybe_ws( 20 ); + + if ( null === $sub['matcher'] ) { + return $out . ']'; + } + + $matcher_strings = array( + 'exact' => '=', + 'one-of' => '~=', + 'exact-or-hyphen-suffixed' => '|=', + 'prefixed' => '^=', + 'suffixed' => '$=', + 'contains' => '*=', + ); + $out .= $matcher_strings[ $sub['matcher'] ] . $this->maybe_ws( 25 ); + + $value = $sub['value']; + $value_as_ident = '' !== $value && $this->can_render_as_ident( $value ) && $this->prng->chance( 45 ); + if ( $value_as_ident ) { + $out .= $this->render_ident( $value ); + } else { + $out .= $this->render_string( $value ); + } + + if ( null !== $sub['modifier'] ) { + // After an ident value, whitespace is mandatory before the modifier. + $out .= $value_as_ident ? $this->ws() : $this->maybe_ws( 60 ); + + if ( 'case-insensitive' === $sub['modifier'] ) { + $out .= $this->prng->chance( 70 ) ? 'i' : 'I'; + } else { + $out .= $this->prng->chance( 70 ) ? 's' : 'S'; + } + } + + return $out . $this->maybe_ws( 25 ) . ']'; + } + + /** + * Whether a value contains only codepoints this renderer is willing to + * put in an ident token (everything can be escaped, but a value ending + * in whitespace as an ident is fragile to read — strings handle those). + */ + private function can_render_as_ident( string $value ): bool { + return '' !== $value; + } + + /** + * Renders a name as a CSS ident token, escaping wherever required and + * sometimes where merely allowed. Parsing the result must yield $name. + */ + private function render_ident( string $name ): string { + $points = utf8_codepoints( $name ); + $count = count( $points ); + $out = ''; + + foreach ( $points as $i => $point ) { + list( $char, $cp ) = $point; + + $is_digit = $cp >= 0x30 && $cp <= 0x39; + $is_ident_char = ( + '-' === $char || + '_' === $char || + $is_digit || + ( $cp >= 0x41 && $cp <= 0x5A ) || + ( $cp >= 0x61 && $cp <= 0x7A ) || + $cp > 0x7F + ); + + $must_escape = ! $is_ident_char + || ( 0 === $i && $is_digit ) + || ( 1 === $i && '-' === $points[0][0] && $is_digit ) + || ( 1 === $count && '-' === $char ); + + if ( $must_escape || $this->prng->chance( 8 ) ) { + $out .= $this->render_escape( $char, $cp ); + } else { + $out .= $char; + } + } + + return $out; + } + + /** + * Renders one codepoint as a CSS escape sequence that decodes back to it. + */ + private function render_escape( string $char, int $cp ): string { + $is_hex_digit = ( $cp >= 0x30 && $cp <= 0x39 ) + || ( $cp >= 0x41 && $cp <= 0x46 ) + || ( $cp >= 0x61 && $cp <= 0x66 ); + $is_newline_like = "\n" === $char || "\r" === $char || "\f" === $char; + + /* + * Identity escapes are only safe for single-byte chars that are not + * hex digits (they would start a hex escape) and not newlines + * (backslash-newline is not a valid escape). + */ + $identity_ok = ! $is_hex_digit && ! $is_newline_like && $cp >= 0x20; + + if ( $identity_ok && $this->prng->chance( 35 ) ) { + return '\\' . $char; + } + + $hex = dechex( $cp ); + if ( $this->prng->chance( 25 ) && strlen( $hex ) < 6 ) { + $hex = str_pad( $hex, $this->prng->int( strlen( $hex ), 6 ), '0', STR_PAD_LEFT ); + } + if ( $this->prng->chance( 30 ) ) { + $hex = strtoupper( $hex ); + } + + // The trailing space is always emitted; it is consumed by the escape. + return '\\' . $hex . ' '; + } + + /** + * Renders a value as a CSS string token. Parsing must yield $value. + */ + private function render_string( string $value ): string { + $quote = $this->prng->chance( 60 ) ? '"' : "'"; + $out = $quote; + $points = utf8_codepoints( $value ); + + foreach ( $points as $point ) { + list( $char, $cp ) = $point; + + if ( "\n" === $char || "\r" === $char || "\f" === $char ) { + // Literal newlines end (break) the string; always hex-escape. + $out .= '\\' . dechex( $cp ) . ' '; + continue; + } + if ( $char === $quote || '\\' === $char ) { + $out .= $this->prng->chance( 60 ) ? '\\' . $char : '\\' . dechex( $cp ) . ' '; + continue; + } + if ( $this->prng->chance( 5 ) ) { + $out .= $this->render_escape( $char, $cp ); + continue; + } + $out .= $char; + } + + // Rarely add a backslash-newline line continuation (decodes to nothing). + if ( $this->prng->chance( 4 ) ) { + $out .= "\\\n"; + } + + return $out . $quote; + } + + private function ws(): string { + $options = array( ' ', ' ', ' ', "\t", "\n", "\f", "\r", ' ', " \t " ); + return $this->prng->choice( $options ); + } + + private function maybe_ws( int $percent ): string { + return $this->prng->chance( $percent ) ? $this->ws() : ''; + } + + private function random_case( string $input ): string { + $out = ''; + for ( $i = 0; $i < strlen( $input ); $i++ ) { + $c = $input[ $i ]; + $out .= $this->prng->chance( 50 ) ? strtoupper( $c ) : strtolower( $c ); + } + return $out; + } + + /* + * ------------------- + * Unsupported selectors + * ------------------- + */ + + private function gen_unsupported(): string { + $kind = $this->prng->weighted( + array( + 'pseudo-class' => 25, + 'pseudo-element' => 15, + 'sibling-combinator' => 20, + 'column-combinator' => 8, + 'namespace-type' => 12, + 'namespace-attr' => 8, + 'non-type-context' => 12, + ) + ); + + switch ( $kind ) { + case 'pseudo-class': + $pseudo = $this->prng->choice( + array( + ':hover', + ':focus', + ':first-child', + ':last-child', + ':nth-child(2n+1)', + ':nth-of-type(3)', + ':not(.excluded)', + ':is(div, span)', + ':where(*)', + ':root', + ':empty', + ':checked', + ':lang(en)', + ':has(> img)', + ) + ); + return $this->render_compound( $this->gen_compound() ) . $pseudo; + + case 'pseudo-element': + $pseudo = $this->prng->choice( array( '::before', '::after', '::first-line', '::first-letter', '::marker', '::placeholder' ) ); + return $this->render_compound( $this->gen_compound() ) . $pseudo; + + case 'sibling-combinator': + $combinator = $this->prng->choice( array( '+', '~' ) ); + return $this->render_compound( $this->gen_compound() ) + . $this->maybe_ws( 60 ) . $combinator . $this->maybe_ws( 60 ) + . $this->render_compound( $this->gen_compound() ); + + case 'column-combinator': + return $this->gen_type_name( true ) + . $this->maybe_ws( 50 ) . '||' . $this->maybe_ws( 50 ) + . $this->gen_type_name( true ); + + case 'namespace-type': + $ns = $this->prng->choice( array( 'svg', 'html', '*', '' ) ); + return $ns . '|' . $this->prng->choice( array( 'title', 'a', 'circle', 'div' ) ); + + case 'namespace-attr': + // `[ns|name]` — must not be confused with the `|=` matcher, + // so the char after `|` must not be `=`. + $ns = $this->prng->choice( array( 'xlink', 'svg', 'xml' ) ); + return '[' . $ns . '|href]'; + + case 'non-type-context': + default: + // A context selector that is not a bare type selector. + $context = $this->prng->choice( array( '.ctx', '#ctx', '[ctx]', 'div.ctx', 'div#ctx', 'div[ctx]', '*.ctx' ) ); + $joiner = $this->prng->chance( 50 ) + ? $this->ws() + : $this->maybe_ws( 50 ) . '>' . $this->maybe_ws( 50 ); + return $context . $joiner . $this->render_compound( $this->gen_compound() ); + } + } + + /* + * ----------------- + * Invalid selectors + * ----------------- + */ + + private function gen_invalid(): string { + $kind = $this->prng->weighted( + array( + 'template' => 45, + 'trailing-garbage' => 25, + 'leading-garbage' => 15, + 'comma-trouble' => 15, + ) + ); + + switch ( $kind ) { + case 'template': + return $this->prng->choice( + array( + '', + ' ', + "\t\n\f ", + '.', + '#', + '[', + ']', + '[]', + '[ ]', + '.5x', + '#5', + '. x', + '..a', + '.#a', + '[a', + '[a=', + '[a=]', + '[=b]', + '[a==b]', + '[a~b]', + '[a!=b]', + '[a=b', + '[a="b]', + "[a='b]", + "[a=\"b\nc\"]", + '[a=b x]', + '[a=b ix]', + '[a=b i', + '[5=b]', + 'a >', + '> a', + 'a > > b', + 'a >> b', + '>', + '-', + '\\', + "a\\\nb", + 'a/**/b', + '/* comment */ a', + '!important', + '@media screen', + '{}', + ';', + 'a;b', + 'a{color:red}', + '()', + 'a()', + '*5', + '%', + 'a%', + ) + ); + + case 'trailing-garbage': + $garbage = $this->prng->choice( array( ':', '(', ')', '{', '}', ';', '!', '@', '%', '/', '=', '|', '^', '$' ) ); + return $this->render_compound( $this->gen_compound() ) . $garbage; + + case 'leading-garbage': + $garbage = $this->prng->choice( array( '%', ';', ')', '}', '=', '~', '+', '/', ',' ) ); + return $garbage . $this->render_compound( $this->gen_compound() ); + + case 'comma-trouble': + default: + $compound = $this->render_compound( $this->gen_compound() ); + return $this->prng->choice( + array( + $compound . ',', + ',' . $compound, + $compound . ',,' . $compound, + $compound . ', ,' . $compound, + $compound . ' , ', + ) + ); + } + } + + /* + * ----- + * Chaos + * ----- + */ + + private function gen_chaos(): string { + $alphabets = array( + 'css' => '.#[]=~|^$*>+,:()"\'\\ \t\n-_', + 'ident' => 'abcXYZ019-_', + 'mixed' => '.#[]=~|^$*>+,:()"\'\\ abcXYZ019-_iIsS', + 'unicode' => '✓Ωé🙂', + ); + + $alphabet = $alphabets[ $this->prng->weighted( + array( + 'css' => 25, + 'ident' => 15, + 'mixed' => 45, + 'unicode' => 15, + ) + ) ]; + + if ( 'unicode' === $alphabet ) { + $points = utf8_codepoints( $alphabet . '.#[]= aZ9' ); + $length = $this->prng->int( 0, 24 ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= $this->prng->choice( $points )[0]; + } + return $out; + } + + $length = $this->prng->int( 0, 40 ); + $out = ''; + for ( $i = 0; $i < $length; $i++ ) { + $out .= $alphabet[ $this->prng->int( 0, strlen( $alphabet ) - 1 ) ]; + } + return $out; + } + + /* + * -------- + * Mutation + * -------- + */ + + private function mutate( string $selector ): string { + $mutation_count = $this->prng->int( 1, 4 ); + $alphabet = '.#[]=~|^$*>+,:()"\'\\ \t\niIsSabcXYZ019-_'; + + for ( $m = 0; $m < $mutation_count; $m++ ) { + $length = strlen( $selector ); + $kind = $this->prng->weighted( + array( + 'insert' => 30, + 'delete' => 25, + 'replace' => 25, + 'duplicate' => 10, + 'case-flip' => 10, + ) + ); + + switch ( $kind ) { + case 'insert': + $at = $this->prng->int( 0, $length ); + $char = $alphabet[ $this->prng->int( 0, strlen( $alphabet ) - 1 ) ]; + $selector = substr( $selector, 0, $at ) . $char . substr( $selector, $at ); + break; + + case 'delete': + if ( $length > 0 ) { + $at = $this->prng->int( 0, $length - 1 ); + $selector = substr( $selector, 0, $at ) . substr( $selector, $at + 1 ); + } + break; + + case 'replace': + if ( $length > 0 ) { + $at = $this->prng->int( 0, $length - 1 ); + $char = $alphabet[ $this->prng->int( 0, strlen( $alphabet ) - 1 ) ]; + $selector = substr( $selector, 0, $at ) . $char . substr( $selector, $at + 1 ); + } + break; + + case 'duplicate': + if ( $length > 0 ) { + $start = $this->prng->int( 0, $length - 1 ); + $span = $this->prng->int( 1, min( 6, $length - $start ) ); + $selector = substr( $selector, 0, $start + $span ) + . substr( $selector, $start, $span ) + . substr( $selector, $start + $span ); + } + break; + + case 'case-flip': + if ( $length > 0 ) { + $at = $this->prng->int( 0, $length - 1 ); + $char = $selector[ $at ]; + $flip = ctype_lower( $char ) ? strtoupper( $char ) : strtolower( $char ); + $selector = substr( $selector, 0, $at ) . $flip . substr( $selector, $at + 1 ); + } + break; + } + } + + return $selector; + } +} diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php new file mode 100644 index 0000000000000..bfa1ec2a580e2 --- /dev/null +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -0,0 +1,589 @@ +fork( 'document' ) ); + $selector = SelectorGenerator::generate( $prng->fork( 'selector' ), $document['pools'] ); + + $failures = array(); + $record = static function ( string $invariant, array $detail ) use ( &$failures ) { + $failures[] = array( + 'invariant' => $invariant, + 'detail' => $detail, + ); + }; + + self::check_document_model( $document, $record ); + + $selector_string = $selector['selector']; + + // --- Parse phase ------------------------------------------------- + + list( $compound_list, $compound_error ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + } + ); + list( $complex_list, $complex_error ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + } + ); + + if ( null !== $compound_error ) { + $record( 'parse-error', array( 'grammar' => 'compound', 'error' => self::describe_throwable( $compound_error ) ) ); + } + if ( null !== $complex_error ) { + $record( 'parse-error', array( 'grammar' => 'complex', 'error' => self::describe_throwable( $complex_error ) ) ); + } + + if ( null === $compound_error && null !== $selector['expectCompound'] && $selector['expectCompound'] !== ( null !== $compound_list ) ) { + $record( + 'parse-expectation', + array( + 'grammar' => 'compound', + 'expected' => $selector['expectCompound'] ? 'parse' : 'null', + 'actual' => null !== $compound_list ? 'parse' : 'null', + ) + ); + } + if ( null === $complex_error && null !== $selector['expectComplex'] && $selector['expectComplex'] !== ( null !== $complex_list ) ) { + $record( + 'parse-expectation', + array( + 'grammar' => 'complex', + 'expected' => $selector['expectComplex'] ? 'parse' : 'null', + 'actual' => null !== $complex_list ? 'parse' : 'null', + ) + ); + } + + if ( null !== $compound_list && null === $complex_list && null === $complex_error ) { + $record( 'compound-implies-complex', array() ); + } + + // Parse determinism: a second parse must agree with the first. + list( $compound_again, ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + } + ); + list( $complex_again, ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + } + ); + if ( ( null === $compound_list ) !== ( null === $compound_again ) || ( null === $complex_list ) !== ( null === $complex_again ) ) { + $record( 'parse-determinism', array( 'note' => 'null-ness changed between identical parses' ) ); + } + + // --- AST extraction ---------------------------------------------- + + $compound_ast = null; + $complex_ast = null; + + if ( null !== $compound_list ) { + list( $compound_ast, $shape_error ) = self::guard( + static function () use ( $compound_list ) { + return AstExtractor::from_compound_list( $compound_list ); + } + ); + if ( null !== $shape_error ) { + $record( 'ast-shape', array( 'grammar' => 'compound', 'error' => self::describe_throwable( $shape_error ) ) ); + } + } + if ( null !== $complex_list ) { + list( $complex_ast, $shape_error ) = self::guard( + static function () use ( $complex_list ) { + return AstExtractor::from_complex_list( $complex_list ); + } + ); + if ( null !== $shape_error ) { + $record( 'ast-shape', array( 'grammar' => 'complex', 'error' => self::describe_throwable( $shape_error ) ) ); + } + } + + if ( null !== $compound_ast && null !== $complex_ast && $compound_ast !== $complex_ast ) { + $record( + 'ast-cross-grammar', + array( + 'compoundAst' => $compound_ast, + 'complexAst' => $complex_ast, + ) + ); + } + + if ( null !== $selector['ast'] && null !== $complex_ast && $selector['ast'] !== $complex_ast ) { + $record( + 'ast-mismatch', + array( + 'generatedAst' => $selector['ast'], + 'parsedAst' => $complex_ast, + ) + ); + } + + // --- Match phase --------------------------------------------------- + + if ( null !== $complex_ast ) { + $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); + self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); + } elseif ( null === $complex_list && null === $complex_error ) { + self::check_select_rejection( 'html', $selector_string, $document, $record ); + } + + if ( null !== $compound_ast ) { + $expected = ReferenceMatcher::expected_tag_processor_matches( $compound_ast, $document['model'] ); + self::check_select_matches( 'tag', $selector_string, $document, $expected, $record ); + } elseif ( null === $compound_list && null === $compound_error ) { + self::check_select_rejection( 'tag', $selector_string, $document, $record ); + } + + $digest = sha1( + json_encode_safe( + array( + $selector_string, + $document['html'], + null !== $compound_list, + null !== $complex_list, + $compound_ast, + $complex_ast, + array_map( + static function ( $failure ) { + return $failure['invariant']; + }, + $failures + ), + ) + ) + ); + + return array( + 'seed' => $seed, + 'bucket' => $selector['bucket'], + 'digest' => $digest, + 'failures' => $failures, + 'selector' => $selector_string, + 'html' => $document['html'], + ); + } + + /** + * Verifies that both processors see exactly the modeled element list — + * this guards the oracle itself against renderer/model drift. + */ + private static function check_document_model( array $document, callable $record ): void { + $expected = array(); + foreach ( DocumentGenerator::flatten_with_ancestors( $document['model'] ) as $pair ) { + list( $element, $ancestors ) = $pair; + $expected[] = array( + strtoupper( ascii_strtolower( $element['tag'] ) ), + $element['fid'], + count( $ancestors ) + 1, + ); + } + + list( $actual, $error ) = self::guard( + static function () use ( $document ) { + $processor = \WP_HTML_Processor::create_full_parser( $document['html'] ); + $out = array(); + while ( $processor->next_tag() ) { + $fid = $processor->get_attribute( 'data-fid' ); + $out[] = array( + (string) $processor->get_tag(), + is_string( $fid ) ? $fid : '(missing)', + count( $processor->get_breadcrumbs() ), + ); + } + if ( null !== $processor->get_last_error() ) { + throw new \RuntimeException( 'Processor error: ' . $processor->get_last_error() ); + } + return $out; + } + ); + + if ( null !== $error ) { + $record( 'model-desync', array( 'processor' => 'html', 'error' => self::describe_throwable( $error ) ) ); + return; + } + + if ( $actual !== $expected ) { + $record( + 'model-desync', + array( + 'processor' => 'html', + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + + // The tag processor must see the same elements ( without breadcrumbs ). + $expected_tags = array(); + foreach ( $expected as $row ) { + $expected_tags[] = array( $row[0], $row[1] ); + } + + list( $actual_tags, $tag_error ) = self::guard( + static function () use ( $document ) { + $processor = new \WP_HTML_Tag_Processor( $document['html'] ); + $out = array(); + while ( $processor->next_tag() ) { + $fid = $processor->get_attribute( 'data-fid' ); + $out[] = array( + (string) $processor->get_tag(), + is_string( $fid ) ? $fid : '(missing)', + ); + } + return $out; + } + ); + + if ( null !== $tag_error ) { + $record( 'model-desync', array( 'processor' => 'tag', 'error' => self::describe_throwable( $tag_error ) ) ); + return; + } + + if ( $actual_tags !== $expected_tags ) { + $record( + 'model-desync', + array( + 'processor' => 'tag', + 'expected' => $expected_tags, + 'actual' => $actual_tags, + ) + ); + } + } + + /** + * Runs a select() loop on a parseable selector and compares the match set + * against the reference matcher. + * + * @param string $target 'html' or 'tag'. + */ + private static function check_select_matches( string $target, string $selector_string, array $document, array $expected, callable $record ): void { + Bootstrap::reset_doing_it_wrong(); + + list( $actual, $error ) = self::guard( + static function () use ( $target, $selector_string, $document ) { + $processor = 'html' === $target + ? \WP_HTML_Processor::create_full_parser( $document['html'] ) + : new \WP_HTML_Tag_Processor( $document['html'] ); + + $matches = array(); + $iterations = 0; + while ( $processor->select( $selector_string ) ) { + $fid = $processor->get_attribute( 'data-fid' ); + $matches[] = is_string( $fid ) ? $fid : '(missing-fid:' . $processor->get_tag() . ')'; + if ( ++$iterations > self::SELECT_ITERATION_LIMIT ) { + throw new \RuntimeException( 'select() did not terminate within the iteration limit.' ); + } + } + + if ( $processor instanceof \WP_HTML_Processor ) { + if ( null !== $processor->get_last_error() ) { + throw new \RuntimeException( 'Processor error state: ' . $processor->get_last_error() ); + } + if ( null !== $processor->get_unsupported_exception() ) { + throw new \RuntimeException( 'Processor unsupported state: ' . $processor->get_unsupported_exception()->getMessage() ); + } + } + + return $matches; + } + ); + + if ( null !== $error ) { + $record( + 'match-error', + array( + 'target' => $target, + 'error' => self::describe_throwable( $error ), + ) + ); + return; + } + + $doing_it_wrong = Bootstrap::doing_it_wrong_calls(); + if ( array() !== $doing_it_wrong ) { + $record( + 'doing-it-wrong-unexpected', + array( + 'target' => $target, + 'calls' => $doing_it_wrong, + ) + ); + } + + if ( $actual !== $expected ) { + $record( + 'match-mismatch-' . $target, + array( + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + } + + /** + * For unparseable selectors: select() must return false, leave the + * processor usable, and report misuse exactly once per call. + */ + private static function check_select_rejection( string $target, string $selector_string, array $document, callable $record ): void { + Bootstrap::reset_doing_it_wrong(); + + list( $results, $error ) = self::guard( + static function () use ( $target, $selector_string, $document ) { + $processor = 'html' === $target + ? \WP_HTML_Processor::create_full_parser( $document['html'] ) + : new \WP_HTML_Tag_Processor( $document['html'] ); + + // Two calls: the second exercises the parse cache. + return array( $processor->select( $selector_string ), $processor->select( $selector_string ) ); + } + ); + + if ( null !== $error ) { + $record( + 'match-error', + array( + 'target' => $target, + 'rejected' => true, + 'error' => self::describe_throwable( $error ), + ) + ); + return; + } + + if ( array( false, false ) !== $results ) { + $record( + 'select-on-null', + array( + 'target' => $target, + 'results' => $results, + ) + ); + } + + $doing_it_wrong = Bootstrap::doing_it_wrong_calls(); + if ( 2 !== count( $doing_it_wrong ) ) { + $record( + 'doing-it-wrong-missing', + array( + 'target' => $target, + 'expectedCalls' => 2, + 'calls' => $doing_it_wrong, + ) + ); + } + } + + /* + * ------------- + * Batch running + * ------------- + */ + + /** + * Runs a batch of sequential seeds. + * + * @return array Summary. + */ + public static function run_batch( array $options ): array { + Bootstrap::load(); + + $start_seed = option_int( $options, 'start-seed', 1 ); + $count = option_int( $options, 'count', 100 ); + $failures_out = option_string( $options, 'failures-out', null ); + $progress_file = option_string( $options, 'progress-file', null ); + $determinism_every = option_int( $options, 'determinism-every', 16 ); + $max_failures = option_int( $options, 'max-failures', 200 ); + + $started_at = microtime( true ); + $failures = 0; + $buckets = array(); + $signatures = array(); + $last_seed = null; + $stop_reason = 'completed'; + + for ( $seed = $start_seed; $seed < $start_seed + $count; $seed++ ) { + if ( $max_failures > 0 && $failures >= $max_failures ) { + $stop_reason = 'max-failures'; + break; + } + if ( null !== $progress_file ) { + file_put_contents( $progress_file, (string) $seed ); + } + + $result = self::run_case( $seed ); + + if ( $determinism_every > 0 && 0 === $seed % $determinism_every ) { + $repeat = self::run_case( $seed ); + if ( $repeat['digest'] !== $result['digest'] ) { + $result['failures'][] = array( + 'invariant' => 'case-determinism', + 'detail' => array( + 'firstDigest' => $result['digest'], + 'secondDigest' => $repeat['digest'], + ), + ); + } + } + + $buckets[ $result['bucket'] ] = ( $buckets[ $result['bucket'] ] ?? 0 ) + 1; + $last_seed = $seed; + + foreach ( $result['failures'] as $failure ) { + ++$failures; + $signature = self::signature( $failure ); + $signatures[ $signature ] = ( $signatures[ $signature ] ?? 0 ) + 1; + + $entry = array( + 'kind' => 'css-selector-fuzz-failure', + 'seed' => $result['seed'], + 'bucket' => $result['bucket'], + 'invariant' => $failure['invariant'], + 'signature' => $signature, + 'selector' => printable_bytes( $result['selector'] ), + 'selectorBase64' => base64_encode( $result['selector'] ), + 'htmlBase64' => base64_encode( $result['html'] ), + 'detail' => $failure['detail'], + ); + if ( null !== $failures_out ) { + append_ndjson( $failures_out, $entry ); + } else { + fwrite( STDERR, json_encode_safe( $entry ) . "\n" ); + } + } + } + + return array( + 'kind' => 'css-selector-fuzz-batch-summary', + 'startSeed' => $start_seed, + 'count' => $count, + 'lastSeed' => $last_seed, + 'failures' => $failures, + 'buckets' => $buckets, + 'signatures' => $signatures, + 'stopReason' => $stop_reason, + 'durationMs' => (int) round( 1000 * ( microtime( true ) - $started_at ) ), + ); + } + + /** Stable identity for de-duplicating equivalent failures. */ + private static function signature( array $failure ): string { + $parts = array( $failure['invariant'] ); + if ( isset( $failure['detail']['grammar'] ) ) { + $parts[] = $failure['detail']['grammar']; + } + if ( isset( $failure['detail']['target'] ) ) { + $parts[] = $failure['detail']['target']; + } + if ( isset( $failure['detail']['error']['class'] ) ) { + $parts[] = $failure['detail']['error']['class']; + $parts[] = preg_replace( '/[0-9]+/', 'N', (string) ( $failure['detail']['error']['message'] ?? '' ) ); + } + return substr( sha1( implode( '|', $parts ) ), 0, 12 ) . ':' . $failure['invariant']; + } + + /* + * ------- + * Helpers + * ------- + */ + + /** + * Calls $fn with PHP warnings/notices converted to exceptions. + * + * @return array{0: mixed, 1: \Throwable|null} + */ + private static function guard( callable $fn ): array { + set_error_handler( + static function ( $severity, $message, $file, $line ) { + if ( E_DEPRECATED === $severity || E_USER_DEPRECATED === $severity ) { + return true; + } + throw new \ErrorException( $message, 0, $severity, $file, $line ); + } + ); + try { + return array( $fn(), null ); + } catch ( \Throwable $e ) { + return array( null, $e ); + } finally { + restore_error_handler(); + } + } + + public static function describe_throwable( \Throwable $e ): array { + $root = repo_root() . DIRECTORY_SEPARATOR; + return array( + 'class' => get_class( $e ), + 'message' => $e->getMessage(), + 'at' => str_replace( $root, '', $e->getFile() ) . ':' . $e->getLine(), + 'trace' => array_slice( + array_map( + static function ( $frame ) use ( $root ) { + $location = isset( $frame['file'] ) + ? str_replace( $root, '', $frame['file'] ) . ':' . ( $frame['line'] ?? '?' ) + : '[internal]'; + $callable = ( $frame['class'] ?? '' ) . ( $frame['type'] ?? '' ) . ( $frame['function'] ?? '' ); + return $location . ' ' . $callable; + }, + $e->getTrace() + ), + 0, + 6 + ), + ); + } +} diff --git a/tools/css-selector-fuzz/lib/autoload.php b/tools/css-selector-fuzz/lib/autoload.php new file mode 100644 index 0000000000000..ffc0e572ed9c1 --- /dev/null +++ b/tools/css-selector-fuzz/lib/autoload.php @@ -0,0 +1,9 @@ + array() ); + $count = count( $argv ); + for ( $i = 1; $i < $count; $i++ ) { + $arg = $argv[ $i ]; + if ( 0 === strpos( $arg, '--' ) ) { + $name = substr( $arg, 2 ); + if ( false !== strpos( $name, '=' ) ) { + list( $name, $value ) = explode( '=', $name, 2 ); + $options[ $name ] = $value; + } elseif ( $i + 1 < $count && 0 !== strpos( $argv[ $i + 1 ], '--' ) ) { + $options[ $name ] = $argv[ ++$i ]; + } else { + $options[ $name ] = true; + } + } else { + $options['_'][] = $arg; + } + } + return $options; +} + +function option_string( array $options, string $name, ?string $default = null ): ?string { + if ( ! array_key_exists( $name, $options ) || true === $options[ $name ] ) { + return $default; + } + return (string) $options[ $name ]; +} + +function option_int( array $options, string $name, int $default ): int { + $value = option_string( $options, $name, null ); + return null === $value ? $default : (int) $value; +} + +function option_float( array $options, string $name, float $default ): float { + $value = option_string( $options, $name, null ); + return null === $value ? $default : (float) $value; +} + +function option_bool( array $options, string $name, bool $default ): bool { + if ( ! array_key_exists( $name, $options ) ) { + return $default; + } + $value = $options[ $name ]; + if ( true === $value ) { + return true; + } + return in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); +} + +function ensure_dir( string $dir ): void { + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0777, true ) && ! is_dir( $dir ) ) { + throw new \RuntimeException( "Could not create directory: {$dir}" ); + } +} + +function json_encode_safe( $value ): string { + $encoded = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); + if ( false === $encoded ) { + $encoded = json_encode( array( 'jsonError' => json_last_error_msg() ) ); + } + return $encoded; +} + +function write_json_file( string $path, $value ): void { + file_put_contents( $path, json_encode_safe( $value ) . "\n" ); +} + +function read_json_file( string $path ): ?array { + if ( ! is_file( $path ) ) { + return null; + } + $decoded = json_decode( (string) file_get_contents( $path ), true ); + return is_array( $decoded ) ? $decoded : null; +} + +function append_ndjson( string $path, array $value ): void { + file_put_contents( $path, json_encode_safe( $value ) . "\n", FILE_APPEND | LOCK_EX ); +} + +function timestamp(): string { + return gmdate( 'Ymd-His' ); +} + +/** + * Renders bytes for human inspection: printable ASCII passes through, + * everything else becomes \xHH. + */ +function printable_bytes( string $bytes, int $max_length = 4096 ): string { + $out = ''; + $truncated = strlen( $bytes ) > $max_length; + $bytes = substr( $bytes, 0, $max_length ); + for ( $i = 0; $i < strlen( $bytes ); $i++ ) { + $c = $bytes[ $i ]; + $o = ord( $c ); + if ( $o >= 0x20 && $o <= 0x7E ) { + $out .= '\\' === $c ? '\\\\' : $c; + } else { + $out .= sprintf( '\\x%02X', $o ); + } + } + return $out . ( $truncated ? '…(truncated)' : '' ); +} + +function git_metadata(): array { + $head = trim( (string) shell_exec( 'git -C ' . escapeshellarg( repo_root() ) . ' rev-parse HEAD 2>/dev/null' ) ); + $branch = trim( (string) shell_exec( 'git -C ' . escapeshellarg( repo_root() ) . ' rev-parse --abbrev-ref HEAD 2>/dev/null' ) ); + return array( + 'head' => '' !== $head ? $head : null, + 'branch' => '' !== $branch ? $branch : null, + ); +} + +function ascii_strtolower( string $input ): string { + return strtr( $input, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' ); +} + +/** + * Splits a valid UTF-8 string into codepoints. + * + * @return array Pairs of ( utf8 bytes, codepoint value ). + */ +function utf8_codepoints( string $input ): array { + $out = array(); + $len = strlen( $input ); + $i = 0; + while ( $i < $len ) { + $byte = ord( $input[ $i ] ); + if ( $byte < 0x80 ) { + $size = 1; + $cp = $byte; + } elseif ( 0xC0 === ( $byte & 0xE0 ) ) { + $size = 2; + $cp = $byte & 0x1F; + } elseif ( 0xE0 === ( $byte & 0xF0 ) ) { + $size = 3; + $cp = $byte & 0x0F; + } else { + $size = 4; + $cp = $byte & 0x07; + } + $size = min( $size, $len - $i ); + for ( $j = 1; $j < $size; $j++ ) { + $cp = ( $cp << 6 ) | ( ord( $input[ $i + $j ] ) & 0x3F ); + } + $out[] = array( substr( $input, $i, $size ), $cp ); + $i += $size; + } + return $out; +} diff --git a/tools/css-selector-fuzz/lib/wp-stubs.php b/tools/css-selector-fuzz/lib/wp-stubs.php new file mode 100644 index 0000000000000..ec9b154ee58d6 --- /dev/null +++ b/tools/css-selector-fuzz/lib/wp-stubs.php @@ -0,0 +1,62 @@ + (string) $function_name, + 'message' => (string) $message, + ); + } +} + +if ( ! function_exists( '_deprecated_argument' ) ) { + function _deprecated_argument( $function_name, $version, $message = '' ) { + } +} + +if ( ! function_exists( 'wp_trigger_error' ) ) { + function wp_trigger_error( $function_name, $message, $error_level = E_USER_NOTICE ) { + $GLOBALS['css_selector_fuzz_doing_it_wrong'][] = array( + 'function' => (string) $function_name, + 'message' => (string) $message, + ); + } +} + +if ( ! function_exists( 'wp_kses_uri_attributes' ) ) { + function wp_kses_uri_attributes() { + return array( + 'action', + 'archive', + 'background', + 'cite', + 'classid', + 'codebase', + 'data', + 'formaction', + 'href', + 'icon', + 'longdesc', + 'manifest', + 'poster', + 'profile', + 'src', + 'usemap', + 'xmlns', + ); + } +} diff --git a/tools/css-selector-fuzz/replay.php b/tools/css-selector-fuzz/replay.php new file mode 100644 index 0000000000000..38ffb8a43678e --- /dev/null +++ b/tools/css-selector-fuzz/replay.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + bar' [--html '
'] + */ + +require_once __DIR__ . '/lib/autoload.php'; + +use CssSelectorFuzz\Bootstrap; +use CssSelectorFuzz\Worker; +use function CssSelectorFuzz\json_encode_safe; +use function CssSelectorFuzz\option_bool; +use function CssSelectorFuzz\option_int; +use function CssSelectorFuzz\option_string; +use function CssSelectorFuzz\parse_cli_options; +use function CssSelectorFuzz\printable_bytes; + +$options = parse_cli_options( $argv ); + +$probe_selector = option_string( $options, 'selector', null ); +if ( null !== $probe_selector ) { + // Quick probe mode: parse a selector and report what the API does with it. + Bootstrap::load(); + + $compound = \WP_CSS_Compound_Selector_List::from_selectors( $probe_selector ); + $complex = \WP_CSS_Complex_Selector_List::from_selectors( $probe_selector ); + + $report = array( + 'selector' => printable_bytes( $probe_selector ), + 'compoundList' => null === $compound ? null : \CssSelectorFuzz\AstExtractor::from_compound_list( $compound ), + 'complexList' => null === $complex ? null : \CssSelectorFuzz\AstExtractor::from_complex_list( $complex ), + ); + + $html = option_string( $options, 'html', null ); + if ( null !== $html && null !== $complex ) { + $processor = \WP_HTML_Processor::create_full_parser( $html ); + $matches = array(); + while ( $processor->select( $probe_selector ) ) { + $matches[] = array( + 'tag' => $processor->get_tag(), + 'breadcrumbs' => $processor->get_breadcrumbs(), + ); + } + $report['htmlProcessorMatches'] = $matches; + } + + echo json_encode_safe( $report ) . "\n"; + exit( 0 ); +} + +$seed = option_int( $options, 'seed', -1 ); +if ( $seed < 0 ) { + echo "Usage: php tools/css-selector-fuzz/replay.php --seed N [--json] [--show-html]\n"; + echo " php tools/css-selector-fuzz/replay.php --selector 'div > .cls' [--html '
']\n"; + exit( 1 ); +} + +$result = Worker::run_case( $seed ); + +if ( option_bool( $options, 'json', false ) ) { + echo json_encode_safe( $result ) . "\n"; + exit( array() === $result['failures'] ? 0 : 2 ); +} + +echo "seed: {$result['seed']}\n"; +echo "bucket: {$result['bucket']}\n"; +echo 'selector: ' . printable_bytes( $result['selector'] ) . "\n"; +echo "digest: {$result['digest']}\n"; + +if ( option_bool( $options, 'show-html', false ) ) { + echo "html: " . printable_bytes( $result['html'] ) . "\n"; +} + +if ( array() === $result['failures'] ) { + echo "failures: none\n"; + exit( 0 ); +} + +echo 'failures: ' . count( $result['failures'] ) . "\n"; +foreach ( $result['failures'] as $i => $failure ) { + echo "--- failure {$i}: {$failure['invariant']} ---\n"; + echo json_encode_safe( $failure['detail'] ) . "\n"; +} +exit( 2 ); diff --git a/tools/css-selector-fuzz/runner.php b/tools/css-selector-fuzz/runner.php new file mode 100644 index 0000000000000..3dbdf0ee66cbe --- /dev/null +++ b/tools/css-selector-fuzz/runner.php @@ -0,0 +1,267 @@ +#!/usr/bin/env php + array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + $started = microtime( true ); + $proc = proc_open( $command, $descriptors, $pipes, repo_root() ); + if ( ! is_resource( $proc ) ) { + return array( + 'code' => null, + 'timedOut' => false, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'durationMs' => 0, + ); + } + + fclose( $pipes[0] ); + stream_set_blocking( $pipes[1], false ); + stream_set_blocking( $pipes[2], false ); + + $stdout = ''; + $stderr = ''; + $timed_out = false; + $deadline = $started + $timeout_ms / 1000; + + while ( true ) { + $status = proc_get_status( $proc ); + $stdout .= (string) stream_get_contents( $pipes[1] ); + $stderr .= (string) stream_get_contents( $pipes[2] ); + + if ( ! $status['running'] ) { + $code = $status['exitcode']; + break; + } + if ( microtime( true ) > $deadline ) { + $timed_out = true; + proc_terminate( $proc, 9 ); + $code = null; + break; + } + usleep( 10000 ); + } + + $stdout .= (string) stream_get_contents( $pipes[1] ); + $stderr .= (string) stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + proc_close( $proc ); + + return array( + 'code' => $code, + 'timedOut' => $timed_out, + 'stdout' => $stdout, + 'stderr' => $stderr, + 'durationMs' => (int) round( 1000 * ( microtime( true ) - $started ) ), + ); +} + +/** Extracts the batch summary from worker stdout, or null. */ +function css_selector_fuzz_worker_summary( string $stdout ): ?array { + foreach ( array_reverse( explode( "\n", trim( $stdout ) ) ) as $line ) { + $decoded = json_decode( $line, true ); + if ( is_array( $decoded ) && 'css-selector-fuzz-batch-summary' === ( $decoded['kind'] ?? null ) ) { + return $decoded; + } + } + return null; +} + +$options = parse_cli_options( $argv ); +if ( option_bool( $options, 'help', false ) || option_bool( $options, 'h', false ) ) { + echo "Usage: php tools/css-selector-fuzz/runner.php [--start-seed N] [--max-seeds N] [--duration-seconds N] [--chunk-size N] [--timeout-ms N] [--output-dir DIR] [--stop-on-failure]\n"; + exit( 0 ); +} + +$start_seed = option_int( $options, 'start-seed', 1 ); +$max_seeds = option_int( $options, 'max-seeds', 1000 ); +$duration_seconds = option_int( $options, 'duration-seconds', 120 ); +$chunk_size = max( 1, option_int( $options, 'chunk-size', 200 ) ); +$timeout_ms = option_int( $options, 'timeout-ms', 0 ); +$stop_on_failure = option_bool( $options, 'stop-on-failure', false ); +$output_dir = option_string( $options, 'output-dir', repo_root() . '/artifacts/css-selector-fuzz/run-' . timestamp() ); + +if ( $max_seeds < 1 ) { + fwrite( STDERR, "--max-seeds must be at least 1; refusing to run unbounded.\n" ); + exit( 1 ); +} +if ( 0 === $timeout_ms ) { + // Generous per-chunk budget: ~50ms per case plus startup. + $timeout_ms = $chunk_size * 50 + 10000; +} + +ensure_dir( $output_dir ); +$failures_path = $output_dir . '/failures.ndjson'; +$state_path = $output_dir . '/state.json'; +$worker_script = __DIR__ . '/worker.php'; + +$state = array( + 'kind' => 'css-selector-fuzz-runner-state', + 'startedAt' => gmdate( 'c' ), + 'updatedAt' => gmdate( 'c' ), + 'git' => git_metadata(), + 'phpVersion' => PHP_VERSION, + 'outputDir' => $output_dir, + 'startSeed' => $start_seed, + 'maxSeeds' => $max_seeds, + 'durationSeconds' => $duration_seconds, + 'chunkSize' => $chunk_size, + 'casesCompleted' => 0, + 'failures' => 0, + 'crashes' => 0, + 'buckets' => array(), + 'signatures' => array(), + 'nextSeed' => $start_seed, + 'stopReason' => null, +); +write_json_file( $state_path, $state ); + +$deadline = $duration_seconds > 0 ? microtime( true ) + $duration_seconds : null; +$seed = $start_seed; +$end_seed = $start_seed + $max_seeds; + +while ( $seed < $end_seed ) { + if ( null !== $deadline && microtime( true ) > $deadline ) { + $state['stopReason'] = 'duration-elapsed'; + break; + } + + $count = min( $chunk_size, $end_seed - $seed ); + $args = array( + $worker_script, + '--start-seed', + (string) $seed, + '--count', + (string) $count, + '--failures-out', + $failures_path, + '--progress-file', + $output_dir . '/progress.txt', + ); + + $proc = css_selector_fuzz_run_php( $args, $timeout_ms ); + $summary = css_selector_fuzz_worker_summary( $proc['stdout'] ); + + if ( null === $summary ) { + /* + * The worker crashed, hung, or died fatally. Re-run each seed of the + * chunk in its own process to attribute the crash. + */ + fwrite( STDERR, "chunk seed={$seed} count={$count}: worker crashed/hung; isolating…\n" ); + for ( $isolated = $seed; $isolated < $seed + $count; $isolated++ ) { + $single = css_selector_fuzz_run_php( + array( + $worker_script, + '--start-seed', + (string) $isolated, + '--count', + '1', + '--failures-out', + $failures_path, + '--determinism-every', + '0', + ), + max( 5000, (int) ( $timeout_ms / $count ) + 5000 ) + ); + $single_summary = css_selector_fuzz_worker_summary( $single['stdout'] ); + if ( null === $single_summary ) { + ++$state['crashes']; + ++$state['failures']; + append_ndjson( + $failures_path, + array( + 'kind' => 'css-selector-fuzz-failure', + 'seed' => $isolated, + 'invariant' => $single['timedOut'] ? 'worker-timeout' : 'worker-crash', + 'signature' => $single['timedOut'] ? 'worker-timeout' : 'worker-crash', + 'exitCode' => $single['code'], + 'stderrTail' => substr( $single['stderr'], -2000 ), + ) + ); + $key = $single['timedOut'] ? 'worker-timeout' : 'worker-crash'; + $state['signatures'][ $key ] = ( $state['signatures'][ $key ] ?? 0 ) + 1; + } else { + ++$state['casesCompleted']; + $state['failures'] += $single_summary['failures']; + foreach ( $single_summary['signatures'] as $signature => $signature_count ) { + $state['signatures'][ $signature ] = ( $state['signatures'][ $signature ] ?? 0 ) + $signature_count; + } + } + } + } else { + $state['casesCompleted'] += array_sum( $summary['buckets'] ); + $state['failures'] += $summary['failures']; + foreach ( $summary['buckets'] as $bucket => $bucket_count ) { + $state['buckets'][ $bucket ] = ( $state['buckets'][ $bucket ] ?? 0 ) + $bucket_count; + } + foreach ( $summary['signatures'] as $signature => $signature_count ) { + $state['signatures'][ $signature ] = ( $state['signatures'][ $signature ] ?? 0 ) + $signature_count; + } + } + + $seed += $count; + $state['nextSeed'] = $seed; + $state['updatedAt'] = gmdate( 'c' ); + write_json_file( $state_path, $state ); + + if ( $stop_on_failure && $state['failures'] > 0 ) { + $state['stopReason'] = 'stop-on-failure'; + break; + } +} + +if ( null === $state['stopReason'] ) { + $state['stopReason'] = 'max-seeds'; +} +$state['updatedAt'] = gmdate( 'c' ); +write_json_file( $state_path, $state ); + +echo json_encode_safe( $state ) . "\n"; +exit( 0 === $state['failures'] ? 0 : 2 ); diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php new file mode 100644 index 0000000000000..e92cc3ecd3f7c --- /dev/null +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -0,0 +1,131 @@ +#!/usr/bin/env php +bytes( 64 ) === $b->bytes( 64 ), 'Identical seeds produce identical streams.' ); + +$c = new Prng( '42', 'label' ); +$d = new Prng( '43', 'label' ); +check( $c->bytes( 64 ) !== $d->bytes( 64 ), 'Different seeds produce different streams.' ); + +$e = new Prng( '42', 'fork-test' ); +$f = new Prng( '42', 'fork-test' ); +$fork1 = $e->fork( 'x' ); +$fork2 = $f->fork( 'x' ); +check( $fork1->bytes( 32 ) === $fork2->bytes( 32 ), 'Forked streams are deterministic.' ); + +// --- utf8_codepoints -------------------------------------------------------- + +$points = utf8_codepoints( "a\u{E9}\u{1F600}" ); +check( 3 === count( $points ), 'utf8_codepoints splits into 3 codepoints.' ); +check( 0x61 === $points[0][1] && 0xE9 === $points[1][1] && 0x1F600 === $points[2][1], 'utf8_codepoints decodes values.' ); + +// --- Document generator: model matches parse for many seeds --------------- +// ( Worker::run_case checks this per case as model-desync; here only a couple +// of seeds are sampled for a fast signal. ) + +for ( $seed = 1; $seed <= 3; $seed++ ) { + $document = DocumentGenerator::generate( new Prng( (string) $seed, 'self-check-doc' ) ); + check( is_string( $document['html'] ) && '' !== $document['html'], "Document {$seed} renders." ); + check( str_contains( $document['html'], 'data-fid' ) || false !== strpos( $document['html'], 'data-fid' ), "Document {$seed} has fids." ); +} + +// --- Selector generator expectations over many seeds ----------------------- + +$by_bucket = array(); +for ( $seed = 1; $seed <= 400; $seed++ ) { + $prng = new Prng( (string) $seed, 'self-check-selector' ); + $document = DocumentGenerator::generate( $prng->fork( 'doc' ) ); + $selector = SelectorGenerator::generate( $prng->fork( 'sel' ), $document['pools'] ); + + $by_bucket[ $selector['bucket'] ] = ( $by_bucket[ $selector['bucket'] ] ?? 0 ) + 1; + + $compound = WP_CSS_Compound_Selector_List::from_selectors( $selector['selector'] ); + $complex = WP_CSS_Complex_Selector_List::from_selectors( $selector['selector'] ); + + if ( null !== $selector['expectCompound'] ) { + check( + $selector['expectCompound'] === ( null !== $compound ), + "Seed {$seed} ({$selector['bucket']}): compound parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } + if ( null !== $selector['expectComplex'] ) { + check( + $selector['expectComplex'] === ( null !== $complex ), + "Seed {$seed} ({$selector['bucket']}): complex parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } +} + +check( count( $by_bucket ) >= 5, 'Bucket variety: saw ' . count( $by_bucket ) . ' buckets.' ); + +// --- Known-answer matching cases ------------------------------------------- + +$known_html = '' + . '
' + . '
' + . ''; + +function select_fids( string $html, string $selector ): array { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $out = array(); + while ( $processor->select( $selector ) ) { + $out[] = $processor->get_attribute( 'data-fid' ); + } + return $out; +} + +check( array( 'e4' ) === select_fids( $known_html, '#x' ), 'Known: #x.' ); +check( array( 'e3', 'e4' ) === select_fids( $known_html, '.b' ), 'Known: .b.' ); +check( array( 'e4' ) === select_fids( $known_html, 'div > span.b' ), 'Known: div > span.b.' ); +check( array( 'e7' ) === select_fids( $known_html, 'section em' ), 'Known: section em.' ); +check( array() === select_fids( $known_html, 'section > em' ), 'Known: section > em matches nothing.' ); +check( array( 'e4' ) === select_fids( $known_html, '[data-v|="hello"]' ), 'Known: [data-v|=hello].' ); +check( array( 'e7' ) === select_fids( $known_html, '[lang^="en"]' ), 'Known: [lang^=en].' ); + +// --- Worker end-to-end on a few seeds --------------------------------------- + +for ( $seed = 1; $seed <= 5; $seed++ ) { + $first = Worker::run_case( $seed ); + $second = Worker::run_case( $seed ); + check( $first['digest'] === $second['digest'], "Seed {$seed}: case digest is deterministic." ); +} + +if ( 0 === $failures ) { + echo "self-check OK\n"; + exit( 0 ); +} +echo "self-check FAILED: {$failures} failure(s)\n"; +exit( 1 ); diff --git a/tools/css-selector-fuzz/worker.php b/tools/css-selector-fuzz/worker.php new file mode 100644 index 0000000000000..bdcde442aa943 --- /dev/null +++ b/tools/css-selector-fuzz/worker.php @@ -0,0 +1,34 @@ +#!/usr/bin/env php + 'css-selector-fuzz-worker-fatal', + 'error' => \CssSelectorFuzz\Worker::describe_throwable( $e ), + ) + ) . "\n" + ); + exit( 1 ); +} From d01451c0f1584663881435fbe13b2e9b3dbbc175 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:00:32 +0200 Subject: [PATCH 157/336] CSS selector fuzz: add metamorphic invariants Oracle-free relations checked on every otherwise-clean case whose selector parses: meaning-preserving transforms (re-render with aggressive no-op escapes, type-name case fold, subclass reorder, explicit universal, list-branch duplication) must keep the select() match set byte-identical, and AST-preserving transforms must parse to exactly the transformed AST. Validated two ways: against core with the three FINDINGS.md fixes applied, 1000 seeds run clean; against unpatched core the transforms independently re-find Bug 1 (identity escapes after multibyte) and Bug 3 (off-by-one length guard) without consulting the reference matcher. ASTs containing invalid UTF-8 (parseable chaos/mutated inputs pass raw bytes through into AST names) are excluded: the renderer can only round-trip valid UTF-8. --- tools/css-selector-fuzz/README.md | 9 + tools/css-selector-fuzz/lib/Metamorph.php | 165 ++++++++++++++++++ .../lib/SelectorGenerator.php | 15 +- tools/css-selector-fuzz/lib/Worker.php | 160 +++++++++++++++-- tools/css-selector-fuzz/lib/autoload.php | 1 + 5 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 tools/css-selector-fuzz/lib/Metamorph.php diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 03c2c51d654f7..291bec6cab6f9 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -37,6 +37,15 @@ produces the same document, the same selector, and the same verdict. `_doing_it_wrong` fires exactly once per call (also via the parse cache), and the processor remains usable. - The processor ends with no `get_last_error()`/unsupported state. + - Metamorphic relations (oracle-free, run on otherwise-clean cases whose + selector parsed): meaning-preserving transforms of the selector must + select exactly the same elements as the original, and AST-preserving + transforms must parse to exactly the transformed AST. Transforms: + re-render with fresh whitespace/quoting and aggressive no-op escapes, + ASCII-case-fold of type names, subclass reordering within a compound, + explicit `*` for an omitted type, and selector-list branch duplication. + Skipped for ASTs containing invalid UTF-8 (reachable only from + chaos/mutated inputs), which the renderer cannot round-trip. - Repeating a case yields a byte-identical result digest (determinism). ## Usage diff --git a/tools/css-selector-fuzz/lib/Metamorph.php b/tools/css-selector-fuzz/lib/Metamorph.php new file mode 100644 index 0000000000000..017b65fdd09f5 --- /dev/null +++ b/tools/css-selector-fuzz/lib/Metamorph.php @@ -0,0 +1,165 @@ + + */ + public static function variants( array $list_ast, Prng $prng ): array { + /* + * The WP parser passes raw bytes through: a selector that is not + * valid UTF-8 yields AST names that are not valid UTF-8 (it does + * not substitute U+FFFD). The renderer can only round-trip valid + * UTF-8 names, so such ASTs (only reachable from chaos/mutated + * inputs) are not transformable. + */ + if ( ! self::ast_strings_are_utf8( $list_ast ) ) { + return array(); + } + + $out = array(); + + $out[] = array( + 'name' => 'rerender', + 'selector' => SelectorGenerator::render( $prng->fork( 'rerender' ), $list_ast, true ), + 'ast' => $list_ast, + 'astMustMatch' => true, + ); + + $typecase = self::map_types( + $list_ast, + static function ( string $type ) use ( $prng ): string { + if ( '*' === $type ) { + return $type; + } + $out = ''; + for ( $i = 0; $i < strlen( $type ); $i++ ) { + $c = $type[ $i ]; + $out .= $prng->chance( 50 ) ? strtoupper( $c ) : strtolower( $c ); + } + return $out; + } + ); + if ( $typecase !== $list_ast ) { + $out[] = array( + 'name' => 'typecase', + 'selector' => SelectorGenerator::render( $prng->fork( 'typecase' ), $typecase ), + 'ast' => $typecase, + 'astMustMatch' => true, + ); + } + + $reordered = self::rotate_subs( $list_ast ); + if ( $reordered !== $list_ast ) { + $out[] = array( + 'name' => 'subs-reorder', + 'selector' => SelectorGenerator::render( $prng->fork( 'subs-reorder' ), $reordered ), + 'ast' => $reordered, + 'astMustMatch' => true, + ); + } + + $universal = self::explicit_universal( $list_ast ); + if ( $universal !== $list_ast ) { + $out[] = array( + 'name' => 'universal', + 'selector' => SelectorGenerator::render( $prng->fork( 'universal' ), $universal ), + 'ast' => $universal, + 'astMustMatch' => true, + ); + } + + $duplicated = $list_ast; + $duplicated[] = $list_ast[ $prng->int( 0, count( $list_ast ) - 1 ) ]; + $out[] = array( + 'name' => 'dup-branch', + 'selector' => SelectorGenerator::render( $prng->fork( 'dup-branch' ), $duplicated ), + 'ast' => $duplicated, + 'astMustMatch' => true, + ); + + return $out; + } + + /** Whether every string anywhere in the AST is valid UTF-8. */ + private static function ast_strings_are_utf8( $node ): bool { + if ( is_string( $node ) ) { + return (bool) preg_match( '//u', $node ); + } + if ( is_array( $node ) ) { + foreach ( $node as $child ) { + if ( ! self::ast_strings_are_utf8( $child ) ) { + return false; + } + } + } + return true; + } + + /** Applies $fn to every type-selector name: compound types and context types. */ + private static function map_types( array $list_ast, callable $fn ): array { + foreach ( $list_ast as &$complex ) { + foreach ( $complex['context'] as &$pair ) { + $pair[0] = $fn( $pair[0] ); + } + unset( $pair ); + if ( null !== $complex['self']['type'] ) { + $complex['self']['type'] = $fn( $complex['self']['type'] ); + } + } + unset( $complex ); + return $list_ast; + } + + /** Rotates the subclass list of every compound that has two or more. */ + private static function rotate_subs( array $list_ast ): array { + foreach ( $list_ast as &$complex ) { + $subs = $complex['self']['subs']; + if ( is_array( $subs ) && count( $subs ) >= 2 ) { + $subs[] = array_shift( $subs ); + $complex['self']['subs'] = $subs; + } + } + unset( $complex ); + return $list_ast; + } + + /** Writes an explicit `*` wherever a compound omitted its type selector. */ + private static function explicit_universal( array $list_ast ): array { + foreach ( $list_ast as &$complex ) { + if ( null === $complex['self']['type'] && null !== $complex['self']['subs'] ) { + $complex['self']['type'] = '*'; + } + } + unset( $complex ); + return $list_ast; + } +} diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 53347c083e79b..0b8f0c5c7a5f2 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -36,12 +36,25 @@ class SelectorGenerator { private $prng; /** @var array */ private $pools; + /** @var bool Escape ident codepoints aggressively when rendering. */ + private $escape_boost = false; private function __construct( Prng $prng, array $pools ) { $this->prng = $prng; $this->pools = $pools; } + /** + * Renders a canonical complex-list AST to a selector string. Parsing the + * result must yield exactly the given AST. With $escape_boost, idents are + * escaped far more often (exercises the escape decoder on no-op escapes). + */ + public static function render( Prng $prng, array $list_ast, bool $escape_boost = false ): string { + $generator = new self( $prng, array() ); + $generator->escape_boost = $escape_boost; + return $generator->render_complex_list( $list_ast ); + } + /** * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). * @return array{ @@ -520,7 +533,7 @@ private function render_ident( string $name ): string { || ( 1 === $i && '-' === $points[0][0] && $is_digit ) || ( 1 === $count && '-' === $char ); - if ( $must_escape || $this->prng->chance( 8 ) ) { + if ( $must_escape || $this->prng->chance( $this->escape_boost ? 50 : 8 ) ) { $out .= $this->render_escape( $char, $cp ); } else { $out .= $char; diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index bfa1ec2a580e2..15542649e0709 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -28,6 +28,13 @@ * - select-on-null: select() returned true for an unparseable selector. * - processor-error: the processor entered an error/unsupported state. * - case-determinism: running the full case twice gave different digests. + * - metamorphic-parse: a meaning-preserving transform of a parseable + * selector no longer parses. + * - metamorphic-ast: an AST-preserving transform parsed to a + * different AST. + * - metamorphic-mismatch: a meaning-preserving transform selected a + * different element set than the original. + * - metamorphic-error: parsing/matching a transformed selector raised. */ class Worker { @@ -172,9 +179,10 @@ static function () use ( $complex_list ) { // --- Match phase --------------------------------------------------- + $html_matches = null; if ( null !== $complex_ast ) { - $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); - self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); + $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); + $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); } elseif ( null === $complex_list && null === $complex_error ) { self::check_select_rejection( 'html', $selector_string, $document, $record ); } @@ -186,6 +194,15 @@ static function () use ( $complex_list ) { self::check_select_rejection( 'tag', $selector_string, $document, $record ); } + // --- Metamorphic phase ---------------------------------------------- + // Oracle-free relations: meaning-preserving transforms of the selector + // must select exactly the same elements. Run only on otherwise-clean + // cases so a single root cause does not multiply into noise. + + if ( null !== $complex_ast && null !== $html_matches && array() === $failures ) { + self::check_metamorphic( $complex_ast, $html_matches, $document, $prng->fork( 'metamorph' ), $record ); + } + $digest = sha1( json_encode_safe( array( @@ -304,19 +321,17 @@ static function () use ( $document ) { } /** - * Runs a select() loop on a parseable selector and compares the match set - * against the reference matcher. + * Runs a select() loop over the document, collecting matched data-fids. * * @param string $target 'html' or 'tag'. + * @return array{0: string[]|null, 1: \Throwable|null} */ - private static function check_select_matches( string $target, string $selector_string, array $document, array $expected, callable $record ): void { - Bootstrap::reset_doing_it_wrong(); - - list( $actual, $error ) = self::guard( - static function () use ( $target, $selector_string, $document ) { + private static function collect_matches( string $target, string $selector_string, string $html ): array { + return self::guard( + static function () use ( $target, $selector_string, $html ) { $processor = 'html' === $target - ? \WP_HTML_Processor::create_full_parser( $document['html'] ) - : new \WP_HTML_Tag_Processor( $document['html'] ); + ? \WP_HTML_Processor::create_full_parser( $html ) + : new \WP_HTML_Tag_Processor( $html ); $matches = array(); $iterations = 0; @@ -340,6 +355,19 @@ static function () use ( $target, $selector_string, $document ) { return $matches; } ); + } + + /** + * Runs a select() loop on a parseable selector and compares the match set + * against the reference matcher. + * + * @param string $target 'html' or 'tag'. + * @return string[]|null The actual match set, or null when matching failed. + */ + private static function check_select_matches( string $target, string $selector_string, array $document, array $expected, callable $record ): ?array { + Bootstrap::reset_doing_it_wrong(); + + list( $actual, $error ) = self::collect_matches( $target, $selector_string, $document['html'] ); if ( null !== $error ) { $record( @@ -349,7 +377,7 @@ static function () use ( $target, $selector_string, $document ) { 'error' => self::describe_throwable( $error ), ) ); - return; + return null; } $doing_it_wrong = Bootstrap::doing_it_wrong_calls(); @@ -372,6 +400,111 @@ static function () use ( $target, $selector_string, $document ) { ) ); } + + return $actual; + } + + /** + * Checks the metamorphic relations: each meaning-preserving transform of + * the parsed selector must parse, must (for AST-preserving transforms) + * parse to exactly the transformed AST, and must select exactly the same + * elements the original selector selected. + * + * @param array $complex_ast Canonical AST of the original selector. + * @param string[] $html_matches The original's WP_HTML_Processor match set. + */ + private static function check_metamorphic( array $complex_ast, array $html_matches, array $document, Prng $prng, callable $record ): void { + foreach ( Metamorph::variants( $complex_ast, $prng ) as $variant ) { + $transform = $variant['name']; + $variant_selector = $variant['selector']; + + list( $variant_list, $parse_error ) = self::guard( + static function () use ( $variant_selector ) { + return \WP_CSS_Complex_Selector_List::from_selectors( $variant_selector ); + } + ); + + if ( null !== $parse_error ) { + $record( + 'metamorphic-error', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + 'error' => self::describe_throwable( $parse_error ), + ) + ); + continue; + } + + if ( null === $variant_list ) { + $record( + 'metamorphic-parse', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + ) + ); + continue; + } + + if ( $variant['astMustMatch'] ) { + list( $variant_ast, $shape_error ) = self::guard( + static function () use ( $variant_list ) { + return AstExtractor::from_complex_list( $variant_list ); + } + ); + if ( null !== $shape_error ) { + $record( + 'metamorphic-error', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + 'error' => self::describe_throwable( $shape_error ), + ) + ); + continue; + } + if ( $variant_ast !== $variant['ast'] ) { + $record( + 'metamorphic-ast', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + 'expectedAst' => $variant['ast'], + 'parsedAst' => $variant_ast, + ) + ); + continue; + } + } + + Bootstrap::reset_doing_it_wrong(); + list( $variant_matches, $match_error ) = self::collect_matches( 'html', $variant_selector, $document['html'] ); + + if ( null !== $match_error ) { + $record( + 'metamorphic-error', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + 'error' => self::describe_throwable( $match_error ), + ) + ); + continue; + } + + if ( $variant_matches !== $html_matches ) { + $record( + 'metamorphic-mismatch', + array( + 'transform' => $transform, + 'selector' => printable_bytes( $variant_selector ), + 'expected' => $html_matches, + 'actual' => $variant_matches, + ) + ); + } + } } /** @@ -528,6 +661,9 @@ private static function signature( array $failure ): string { if ( isset( $failure['detail']['target'] ) ) { $parts[] = $failure['detail']['target']; } + if ( isset( $failure['detail']['transform'] ) ) { + $parts[] = $failure['detail']['transform']; + } if ( isset( $failure['detail']['error']['class'] ) ) { $parts[] = $failure['detail']['error']['class']; $parts[] = preg_replace( '/[0-9]+/', 'N', (string) ( $failure['detail']['error']['message'] ?? '' ) ); diff --git a/tools/css-selector-fuzz/lib/autoload.php b/tools/css-selector-fuzz/lib/autoload.php index ffc0e572ed9c1..bc22e73f42f9c 100644 --- a/tools/css-selector-fuzz/lib/autoload.php +++ b/tools/css-selector-fuzz/lib/autoload.php @@ -6,4 +6,5 @@ require_once __DIR__ . '/SelectorGenerator.php'; require_once __DIR__ . '/AstExtractor.php'; require_once __DIR__ . '/ReferenceMatcher.php'; +require_once __DIR__ . '/Metamorph.php'; require_once __DIR__ . '/Worker.php'; From 43d2f68521025c36ef223da039a8a668bf93bb6d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:07:07 +0200 Subject: [PATCH 158/336] CSS selector fuzz: add path-directed generation bucket Synthesizes selectors from a real element of the generated model tree: type from its tag, subclasses from its actual classes/id/attributes (with operators derived from the real value: prefixes, suffixes, substrings, whitespace words, hyphen prefixes, case-flipped operands under the i modifier), and a context chain drawn from its actual ancestors where > is only used for the immediately-next ancestor. The element is guaranteed by construction to be in the match set; a near-miss flip (wrong type/class/attribute, combinator tighten/loosen) inverts or preserves that guarantee. The guarantee is checked against the reference matcher as the new path-expectation invariant, so a generator/oracle disagreement is itself a finding. Positive-match rate for combinator-bearing selectors: 68.3% in this bucket vs 13.9% in supported-complex (3000-seed measurement); 1000 seeds run clean against core with the three known fixes applied. --- tools/css-selector-fuzz/README.md | 11 +- .../lib/SelectorGenerator.php | 348 +++++++++++++++++- tools/css-selector-fuzz/lib/Worker.php | 37 +- 3 files changed, 383 insertions(+), 13 deletions(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 291bec6cab6f9..e8a0f918a0852 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -12,10 +12,19 @@ produces the same document, the same selector, and the same verdict. 1. Generate a random HTML document from a structurally "safe" element set so the model tree is provably identical to the parsed tree (this is itself verified every case — `model-desync`). -2. Generate a selector in one of six buckets: +2. Generate a selector in one of seven buckets: - `supported-compound` — must parse in both grammars; carries intended AST. - `supported-complex` — uses `>`/descendant combinators; must parse only in the complex grammar; carries intended AST. + - `path-directed` — synthesized from a real element of the generated tree + (type from its tag, subclasses from its actual classes/id/attributes, + context chain from its actual ancestors), guaranteed by construction to + match that element — or flipped into a near-miss (wrong type/class/attr + guarantees a non-match; loosening `>` to descendant must keep matching). + The guarantee is asserted against the reference matcher + (`path-expectation`), making most match assertions non-vacuous: + measured positive-match rate for combinator selectors is ~68% in this + bucket vs ~14% in `supported-complex`. - `unsupported` — valid CSS the API intentionally rejects (pseudo-classes and -elements, `+`/`~`/`||` combinators, namespaces, non-type context selectors); must not parse. diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 0b8f0c5c7a5f2..25490e4a90bc2 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -26,6 +26,7 @@ class SelectorGenerator { const BUCKETS = array( 'supported-compound', 'supported-complex', + 'path-directed', 'unsupported', 'invalid', 'chaos', @@ -56,31 +57,48 @@ public static function render( Prng $prng, array $list_ast, bool $escape_boost = } /** - * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). + * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). + * @param array|null $model Root element model; enables the path-directed bucket. * @return array{ * bucket: string, * selector: string, * expectCompound: bool|null, * expectComplex: bool|null, * ast: array|null, + * mustMatchFid: string|null, + * mustNotMatchFid: string|null, * } */ - public static function generate( Prng $prng, array $pools, ?string $bucket = null ): array { + public static function generate( Prng $prng, array $pools, ?array $model = null, ?string $bucket = null ): array { $generator = new self( $prng, $pools ); if ( null === $bucket ) { $bucket = $prng->weighted( - array( - 'supported-compound' => 30, - 'supported-complex' => 25, - 'unsupported' => 15, - 'invalid' => 12, - 'chaos' => 8, - 'mutated' => 10, - ) + null === $model + ? array( + 'supported-compound' => 30, + 'supported-complex' => 25, + 'unsupported' => 15, + 'invalid' => 12, + 'chaos' => 8, + 'mutated' => 10, + ) + : array( + 'supported-compound' => 24, + 'supported-complex' => 20, + 'path-directed' => 22, + 'unsupported' => 12, + 'invalid' => 10, + 'chaos' => 6, + 'mutated' => 6, + ) ); } + if ( 'path-directed' === $bucket && null === $model ) { + $bucket = 'supported-complex'; + } + switch ( $bucket ) { case 'supported-compound': $ast = $generator->gen_complex_list( false ); @@ -102,6 +120,9 @@ public static function generate( Prng $prng, array $pools, ?string $bucket = nul 'ast' => $ast, ); + case 'path-directed': + return $generator->gen_path_directed( $model ); + case 'unsupported': return array( 'bucket' => $bucket, @@ -399,6 +420,313 @@ private function pick_name( string $pool_key ): string { ); } + /* + * ------------------------ + * Path-directed generation + * ------------------------ + * + * Synthesizes a selector from a real element of the model tree so that + * the selector is guaranteed (by construction) to match that element: + * the type comes from its tag, subclasses from its actual classes / id / + * attributes, and the context chain from its actual ancestor tags with + * combinators consistent with the real nesting. Optionally one feature + * is then flipped into a "near-miss" that is guaranteed NOT to match + * the element ( or, for combinator loosening, still guaranteed to ). + */ + + private function gen_path_directed( array $model ): array { + $pairs = DocumentGenerator::flatten_with_ancestors( $model ); + + // Bias toward elements deep enough for a meaningful context chain. + $deep = array(); + foreach ( $pairs as $pair ) { + if ( count( $pair[1] ) >= 2 ) { + $deep[] = $pair; + } + } + if ( array() !== $deep && $this->prng->chance( 75 ) ) { + $pair = $this->prng->choice( $deep ); + } else { + $pair = $this->prng->choice( $pairs ); + } + list( $element, $ancestors ) = $pair; + + $compound = $this->path_compound_for( $element ); + $context = array() !== $ancestors && $this->prng->chance( 75 ) + ? $this->path_context_for( $ancestors ) + : array(); + + $list = array( + array( + 'context' => $context, + 'self' => $compound, + ), + ); + + $must_match = $element['fid']; + $must_not_match = null; + + if ( $this->prng->chance( 40 ) ) { + list( $list, $must_match, $must_not_match ) = $this->path_near_miss( $list, $element ); + } elseif ( $this->prng->chance( 20 ) ) { + // Extra unrelated branch: a list union can only add matches. + $list[] = $this->gen_complex( $this->prng->chance( 30 ) ); + } + + $has_context = false; + foreach ( $list as $complex ) { + if ( array() !== $complex['context'] ) { + $has_context = true; + break; + } + } + + return array( + 'bucket' => 'path-directed', + 'selector' => $this->render_complex_list( $list ), + 'expectCompound' => ! $has_context, + 'expectComplex' => true, + 'ast' => $list, + 'mustMatchFid' => $must_match, + 'mustNotMatchFid' => $must_not_match, + ); + } + + /** A compound selector built only from features the element really has. */ + private function path_compound_for( array $element ): array { + $tag = ascii_strtolower( $element['tag'] ); + + $features = array(); + + $class_value = DocumentGenerator::get_attribute_value( $element, 'class' ); + if ( is_string( $class_value ) ) { + foreach ( preg_split( '/[ \t\n\f\r]+/', $class_value, -1, PREG_SPLIT_NO_EMPTY ) as $word ) { + $features[] = array( 'kind' => 'class', 'name' => $word ); + } + } + + $id_value = DocumentGenerator::get_attribute_value( $element, 'id' ); + if ( is_string( $id_value ) && '' !== $id_value ) { + $features[] = array( 'kind' => 'id', 'name' => $id_value ); + } + + $seen_attrs = array(); + foreach ( $element['attrs'] as $attr ) { + $lower = ascii_strtolower( $attr[0] ); + if ( isset( $seen_attrs[ $lower ] ) || 'data-fid' === $lower ) { + continue; + } + $seen_attrs[ $lower ] = true; + $features[] = $this->path_attr_feature( $lower, $attr[1] ); + } + + $subs = array(); + $available = count( $features ); + if ( $available > 0 ) { + $want = min( $available, $this->prng->weighted( array( 0 => 25, 1 => 40, 2 => 25, 3 => 10 ) ) ); + for ( $i = 0; $i < $want; $i++ ) { + $at = $this->prng->int( 0, count( $features ) - 1 ); + $subs[] = $features[ $at ]; + array_splice( $features, $at, 1 ); + } + } + + $type = null; + if ( array() === $subs || $this->prng->chance( 70 ) ) { + $type = $this->prng->chance( 12 ) ? '*' : ( $this->prng->chance( 30 ) ? $this->random_case( $tag ) : $tag ); + } + + return array( + 'type' => $type, + 'subs' => array() === $subs ? null : $subs, + ); + } + + /** An attribute selector that the (name, value) pair satisfies. */ + private function path_attr_feature( string $name, $value ): array { + $presence = array( + 'kind' => 'attr', + 'name' => $this->prng->chance( 15 ) ? $this->random_case( $name ) : $name, + 'matcher' => null, + 'value' => null, + 'modifier' => null, + ); + + if ( true === $value ) { + // A boolean attribute has the empty string as its value. + $value = ''; + } + if ( ! is_string( $value ) || $this->prng->chance( 30 ) ) { + return $presence; + } + + $points = utf8_codepoints( $value ); + $total = count( $points ); + + $candidates = array( array( 'exact', $value ) ); + + foreach ( preg_split( '/[ \t\n\f\r]+/', $value, -1, PREG_SPLIT_NO_EMPTY ) as $word ) { + $candidates[] = array( 'one-of', $word ); + break; + } + + $hyphen_at = strpos( $value, '-' ); + $candidates[] = array( 'exact-or-hyphen-suffixed', false === $hyphen_at ? $value : substr( $value, 0, $hyphen_at ) ); + + if ( $total > 0 ) { + $slice = static function ( array $points, int $start, int $length ): string { + $out = ''; + for ( $i = $start; $i < $start + $length; $i++ ) { + $out .= $points[ $i ][0]; + } + return $out; + }; + + $candidates[] = array( 'prefixed', $slice( $points, 0, $this->prng->int( 1, $total ) ) ); + $length = $this->prng->int( 1, $total ); + $candidates[] = array( 'suffixed', $slice( $points, $total - $length, $length ) ); + $start = $this->prng->int( 0, $total - 1 ); + $candidates[] = array( 'contains', $slice( $points, $start, $this->prng->int( 1, $total - $start ) ) ); + } + + list( $matcher, $operand ) = $this->prng->choice( $candidates ); + + /* + * `|=` with an operand cut at a hyphen only matches when the operand + * is non-empty and actually a value prefix; an operand equal to the + * value always matches. Guard the degenerate empty-operand cases. + */ + if ( 'exact-or-hyphen-suffixed' === $matcher && '' === $operand && '' !== $value ) { + $matcher = 'exact'; + $operand = $value; + } + if ( in_array( $matcher, array( 'one-of', 'prefixed', 'suffixed', 'contains' ), true ) && '' === $operand ) { + return $presence; + } + + $modifier = null; + if ( $this->prng->chance( 25 ) ) { + if ( $this->prng->chance( 60 ) ) { + $modifier = 'case-insensitive'; + $operand = $this->random_case( $operand ); + } else { + $modifier = 'case-sensitive'; + } + } + + return array( + 'kind' => 'attr', + 'name' => $presence['name'], + 'matcher' => $matcher, + 'value' => $operand, + 'modifier' => $modifier, + ); + } + + /** + * A context chain ( right-to-left ( type, combinator ) pairs ) drawn from + * the element's real ancestors so the chain is satisfied by construction: + * `>` is only used for the immediately-next ancestor, descendant + * combinators may skip generations. + * + * @param array $ancestors Nearest-first ancestor elements. + */ + private function path_context_for( array $ancestors ): array { + $chain = array(); + $pos = 0; + $count = count( $ancestors ); + + while ( $pos < $count && ( array() === $chain || $this->prng->chance( 45 ) ) ) { + $jump = $this->prng->chance( 65 ) ? 0 : $this->prng->int( 0, $count - 1 - $pos ); + $at = $pos + $jump; + + $combinator = ( 0 === $jump && $this->prng->chance( 55 ) ) ? '>' : ' '; + $tag = ascii_strtolower( $ancestors[ $at ]['tag'] ); + $type = $this->prng->chance( 12 ) + ? '*' + : ( $this->prng->chance( 25 ) ? $this->random_case( $tag ) : $tag ); + + $chain[] = array( $type, $combinator ); + $pos = $at + 1; + } + + return $chain; + } + + /** + * Flips one feature of the guaranteed-match selector. Most flips + * guarantee the element no longer matches; loosening a `>` to a + * descendant combinator must keep it matching. + * + * @return array{0: array, 1: string|null, 2: string|null} list, mustMatchFid, mustNotMatchFid. + */ + private function path_near_miss( array $list, array $element ): array { + $complex = $list[0]; + $compound = $complex['self']; + $fid = $element['fid']; + + $flips = array( 'wrong-class', 'wrong-attr' ); + if ( null !== $compound['type'] && '*' !== $compound['type'] ) { + $flips[] = 'wrong-type'; + } + foreach ( $complex['context'] as $pair ) { + if ( '>' === $pair[1] ) { + $flips[] = 'loosen-combinator'; + } + $flips[] = 'tighten-combinator'; + break; + } + + switch ( $this->prng->choice( $flips ) ) { + case 'wrong-type': + $tag = ascii_strtolower( $element['tag'] ); + do { + $other = $this->prng->choice( DocumentGenerator::SAFE_TAGS ); + } while ( $other === $tag ); + $complex['self']['type'] = $this->prng->chance( 25 ) ? $this->random_case( $other ) : $other; + return array( array( $complex ), null, $fid ); + + case 'wrong-attr': + $subs = (array) $complex['self']['subs']; + $subs[] = array( + 'kind' => 'attr', + 'name' => 'zz-no-such-attr', + 'matcher' => null, + 'value' => null, + 'modifier' => null, + ); + $complex['self']['subs'] = $subs; + return array( array( $complex ), null, $fid ); + + case 'loosen-combinator': + // Replacing every `>` with a descendant combinator can only + // widen the context; the element must still match. + foreach ( $complex['context'] as &$pair ) { + $pair[1] = ' '; + } + unset( $pair ); + $list[0] = $complex; + return array( $list, $fid, null ); + + case 'tighten-combinator': + // May or may not still match; no membership expectation. + $at = $this->prng->int( 0, count( $complex['context'] ) - 1 ); + $complex['context'][ $at ][1] = '>'; + $list[0] = $complex; + return array( $list, null, null ); + + case 'wrong-class': + default: + $subs = (array) $complex['self']['subs']; + $subs[] = array( + 'kind' => 'class', + 'name' => 'zz-no-such-class', + ); + $complex['self']['subs'] = $subs; + return array( array( $complex ), null, $fid ); + } + } + /* * --------- * Rendering diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 15542649e0709..59982794d4aa5 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -35,6 +35,9 @@ * - metamorphic-mismatch: a meaning-preserving transform selected a * different element set than the original. * - metamorphic-error: parsing/matching a transformed selector raised. + * - path-expectation: a path-directed selector's guaranteed + * (non-)membership does not hold in the reference + * matcher ( generator/oracle defect ). */ class Worker { @@ -57,7 +60,7 @@ public static function run_case( int $seed ): array { $prng = new Prng( (string) $seed, 'css-selector-fuzz-case' ); $document = DocumentGenerator::generate( $prng->fork( 'document' ) ); - $selector = SelectorGenerator::generate( $prng->fork( 'selector' ), $document['pools'] ); + $selector = SelectorGenerator::generate( $prng->fork( 'selector' ), $document['pools'], $document['model'] ); $failures = array(); $record = static function ( string $invariant, array $detail ) use ( &$failures ) { @@ -181,7 +184,37 @@ static function () use ( $complex_list ) { $html_matches = null; if ( null !== $complex_ast ) { - $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); + $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); + + /* + * Path-directed selectors are guaranteed by construction to match + * ( or, for near-misses, not to match ) a specific element. The + * reference matcher disagreeing means the generator or the + * reference matcher itself is wrong — a fuzzer-side defect. + */ + $must_match = $selector['mustMatchFid'] ?? null; + $must_not_match = $selector['mustNotMatchFid'] ?? null; + if ( null !== $must_match && ! in_array( $must_match, $expected, true ) ) { + $record( + 'path-expectation', + array( + 'expectation' => 'must-match', + 'fid' => $must_match, + 'expected' => $expected, + ) + ); + } + if ( null !== $must_not_match && in_array( $must_not_match, $expected, true ) ) { + $record( + 'path-expectation', + array( + 'expectation' => 'must-not-match', + 'fid' => $must_not_match, + 'expected' => $expected, + ) + ); + } + $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); } elseif ( null === $complex_list && null === $complex_error ) { self::check_select_rejection( 'html', $selector_string, $document, $record ); From c97d0714e79e99e9ee67c8f076405eb353593e84 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:21:15 +0200 Subject: [PATCH 159/336] CSS selector fuzz: parser-derived oracle tree and wild HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the model==parse-tree precondition with TreeCapture: the processor's own parse, captured once per case as flat rows in visit order (tag, attributes, nearest-first ancestor tags — context selectors are type-only, so rows are everything matching can observe). The reference matcher and path-directed generator now consume rows, which also keeps match comparison correct under restructuring: select() emits matches in visit (token) order, not final-DOM order. For safe documents the capture must still agree with the generated model (model-desync, now also covering attributes and quirks mode); that per-case soundness check is what justifies trusting the capture on wild documents. Add WildDocumentGenerator: misnesting, implied end tags and table sections, stray/unclosed tags, foreign content, and five doctype variants, tuned around the processor's unsupported constructs (it bails on foster parenting, complex adoption-agency runs, non-whitespace table text, FORM end tags over open elements); residual bails are absorbed by bounded deterministic regeneration. 1500 seeds clean against core with the three known fixes applied; all three known bug classes still detected on unpatched core. --- tools/css-selector-fuzz/README.md | 19 +- .../lib/DocumentGenerator.php | 37 ++ .../lib/ReferenceMatcher.php | 99 ++--- .../lib/SelectorGenerator.php | 46 +-- tools/css-selector-fuzz/lib/TreeCapture.php | 114 ++++++ .../lib/WildDocumentGenerator.php | 362 ++++++++++++++++++ tools/css-selector-fuzz/lib/Worker.php | 180 +++++---- tools/css-selector-fuzz/lib/autoload.php | 2 + 8 files changed, 717 insertions(+), 142 deletions(-) create mode 100644 tools/css-selector-fuzz/lib/TreeCapture.php create mode 100644 tools/css-selector-fuzz/lib/WildDocumentGenerator.php diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index e8a0f918a0852..48d5192927a59 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -9,10 +9,19 @@ produces the same document, the same selector, and the same verdict. ## What a case does -1. Generate a random HTML document from a structurally "safe" element set so - the model tree is provably identical to the parsed tree (this is itself - verified every case — `model-desync`). -2. Generate a selector in one of seven buckets: +1. Generate a random HTML document — 70% from a structurally "safe" element + set with a known model tree, 30% "wild" (misnested, implied-end-tag, + foreign-content, varied-doctype token soup with no model). +2. Capture the processor's own view of the document as the matching oracle's + ground truth (`TreeCapture`): a flat list of rows in visit order, each + carrying the element's tag, attributes, and ancestor tag list (context + selectors are type-only, so that is everything matching can observe). + For safe documents the capture must agree with the generated model + (`model-desync`) — that soundness check is what justifies trusting the + capture on wild documents. Wild documents that hit a construct the + processor bails on (foster parenting, complex adoption-agency runs) are + deterministically regenerated a bounded number of times. +3. Generate a selector in one of seven buckets: - `supported-compound` — must parse in both grammars; carries intended AST. - `supported-complex` — uses `>`/descendant combinators; must parse only in the complex grammar; carries intended AST. @@ -32,7 +41,7 @@ produces the same document, the same selector, and the same verdict. - `chaos` — arbitrary bytes; no parse expectation. - `mutated` — a supported selector with random byte mutations; no parse expectation. -3. Check invariants: +4. Check invariants: - No PHP error/warning/exception from parsing or matching, ever. - Parse result (instance vs `null`) matches the bucket's expectation. - Anything the compound grammar parses, the complex grammar parses, and diff --git a/tools/css-selector-fuzz/lib/DocumentGenerator.php b/tools/css-selector-fuzz/lib/DocumentGenerator.php index 70e3ab80d2a39..e435edce409c9 100644 --- a/tools/css-selector-fuzz/lib/DocumentGenerator.php +++ b/tools/css-selector-fuzz/lib/DocumentGenerator.php @@ -437,6 +437,43 @@ public static function flatten_with_ancestors( array $element, array $ancestors return $out; } + /** + * Flat element rows ( the TreeCapture row shape ) derived from a model: + * pre-order, tags uppercased, attribute names lowercased with the first + * of duplicates winning — directly comparable to a TreeCapture of the + * rendered document. + */ + public static function rows_from_model( array $model ): array { + $rows = array(); + foreach ( self::flatten_with_ancestors( $model ) as $pair ) { + list( $element, $ancestors ) = $pair; + + $attrs = array(); + $seen = array(); + foreach ( $element['attrs'] as $attr ) { + $lower = ascii_strtolower( $attr[0] ); + if ( isset( $seen[ $lower ] ) ) { + continue; + } + $seen[ $lower ] = true; + $attrs[] = array( $lower, $attr[1] ); + } + + $ancestor_tags = array(); + foreach ( $ancestors as $ancestor ) { + $ancestor_tags[] = strtoupper( ascii_strtolower( $ancestor['tag'] ) ); + } + + $rows[] = array( + 'tag' => strtoupper( ascii_strtolower( $element['tag'] ) ), + 'fid' => $element['fid'], + 'attrs' => $attrs, + 'ancestorTags' => $ancestor_tags, + ); + } + return $rows; + } + /** First attribute value for a name, ASCII case-insensitive; null if absent. */ public static function get_attribute_value( array $element, string $name ) { $comparable = ascii_strtolower( $name ); diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php index fd834a543041d..6af3301cd080b 100644 --- a/tools/css-selector-fuzz/lib/ReferenceMatcher.php +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -2,9 +2,14 @@ namespace CssSelectorFuzz; /** - * Independent implementation of the supported CSS selector semantics, - * operating on the document model produced by DocumentGenerator and the - * canonical selector AST. + * Independent implementation of the supported CSS selector semantics. + * + * Operates on flat element "rows" in visit order — either derived from the + * generated document model or captured from the processor itself + * ( TreeCapture ). Each row carries the element's tag, attributes, and + * ( for the html processor view ) its nearest-first ancestor tag list, + * which is all the supported grammar can observe: context selectors are + * type-only. * * Semantics follow the CSS Selectors Level 4 specification: * - Tag names match ASCII case-insensitively (HTML documents). @@ -24,19 +29,18 @@ class ReferenceMatcher { const WHITESPACE = " \t\r\n\f"; /** - * Expected match list for WP_HTML_Processor::select() over a full document. + * Expected match list for WP_HTML_Processor::select(). * * @param array $list_ast Canonical complex selector list AST. - * @param array $model Root element model ( the `html` element ). + * @param array $rows Element rows in visit order, with ancestorTags. * @param bool $quirks Whether the document parses in quirks mode. - * @return string[] data-fid values in document order. + * @return string[] data-fid values in visit order. */ - public static function expected_html_processor_matches( array $list_ast, array $model, bool $quirks ): array { + public static function expected_html_matches_rows( array $list_ast, array $rows, bool $quirks ): array { $out = array(); - foreach ( DocumentGenerator::flatten_with_ancestors( $model ) as $pair ) { - list( $element, $ancestors ) = $pair; - if ( self::list_matches( $list_ast, $element, $ancestors, $quirks ) ) { - $out[] = $element['fid']; + foreach ( $rows as $row ) { + if ( self::list_matches_row( $list_ast, $row, $quirks ) ) { + $out[] = $row['fid']; } } return $out; @@ -48,39 +52,48 @@ public static function expected_html_processor_matches( array $list_ast, array $ * compound selector list never inspects ancestors. * * @param array $list_ast Canonical complex selector list AST ( contexts must be empty ). - * @param array $model Root element model. - * @return string[] data-fid values in document order. + * @param array $rows Tag-view element rows in token order. + * @return string[] data-fid values in token order. */ - public static function expected_tag_processor_matches( array $list_ast, array $model ): array { + public static function expected_tag_matches_rows( array $list_ast, array $rows ): array { $out = array(); - foreach ( DocumentGenerator::flatten( $model ) as $element ) { - if ( self::list_matches( $list_ast, $element, array(), false ) ) { - $out[] = $element['fid']; + foreach ( $rows as $row ) { + $matched = false; + foreach ( $list_ast as $complex ) { + if ( self::compound_matches( $complex['self'], $row, false ) ) { + $matched = true; + break; + } + } + if ( $matched ) { + $out[] = $row['fid']; } } return $out; } - public static function list_matches( array $list_ast, array $element, array $ancestors, bool $quirks ): bool { + /** Back-compat: expected html-processor matches from a generated model. */ + public static function expected_html_processor_matches( array $list_ast, array $model, bool $quirks ): array { + return self::expected_html_matches_rows( $list_ast, DocumentGenerator::rows_from_model( $model ), $quirks ); + } + + /** Back-compat: expected tag-processor matches from a generated model. */ + public static function expected_tag_processor_matches( array $list_ast, array $model ): array { + return self::expected_tag_matches_rows( $list_ast, DocumentGenerator::rows_from_model( $model ) ); + } + + public static function list_matches_row( array $list_ast, array $row, bool $quirks ): bool { foreach ( $list_ast as $complex ) { - if ( self::complex_matches( $complex, $element, $ancestors, $quirks ) ) { + if ( + self::compound_matches( $complex['self'], $row, $quirks ) && + self::explore_context( $complex['context'], $row['ancestorTags'] ) + ) { return true; } } return false; } - private static function complex_matches( array $complex, array $element, array $ancestors, bool $quirks ): bool { - if ( ! self::compound_matches( $complex['self'], $element, $quirks ) ) { - return false; - } - $ancestor_tags = array(); - foreach ( $ancestors as $ancestor ) { - $ancestor_tags[] = $ancestor['tag']; - } - return self::explore_context( $complex['context'], $ancestor_tags ); - } - /** * @param array $context Right-to-left ( type, combinator ) pairs. * @param string[] $ancestor_tags Nearest-ancestor-first tag names. @@ -114,12 +127,12 @@ private static function explore_context( array $context, array $ancestor_tags ): return false; } - public static function compound_matches( array $compound, array $element, bool $quirks ): bool { - if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $element['tag'] ) ) { + public static function compound_matches( array $compound, array $row, bool $quirks ): bool { + if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $row['tag'] ) ) { return false; } foreach ( (array) $compound['subs'] as $sub ) { - if ( ! self::sub_matches( $sub, $element, $quirks ) ) { + if ( ! self::sub_matches( $sub, $row, $quirks ) ) { return false; } } @@ -130,20 +143,20 @@ private static function type_matches( string $type, string $tag ): bool { return '*' === $type || ascii_strtolower( $type ) === ascii_strtolower( $tag ); } - private static function sub_matches( array $sub, array $element, bool $quirks ): bool { + private static function sub_matches( array $sub, array $row, bool $quirks ): bool { switch ( $sub['kind'] ) { case 'class': - return self::class_matches( $sub['name'], $element, $quirks ); + return self::class_matches( $sub['name'], $row, $quirks ); case 'id': - return self::id_matches( $sub['name'], $element, $quirks ); + return self::id_matches( $sub['name'], $row, $quirks ); case 'attr': - return self::attr_matches( $sub, $element ); + return self::attr_matches( $sub, $row ); } return false; } - private static function class_matches( string $wanted, array $element, bool $quirks ): bool { - $class_value = DocumentGenerator::get_attribute_value( $element, 'class' ); + private static function class_matches( string $wanted, array $row, bool $quirks ): bool { + $class_value = DocumentGenerator::get_attribute_value( $row, 'class' ); if ( ! is_string( $class_value ) ) { return false; } @@ -170,8 +183,8 @@ private static function class_matches( string $wanted, array $element, bool $qui return false; } - private static function id_matches( string $wanted, array $element, bool $quirks ): bool { - $id = DocumentGenerator::get_attribute_value( $element, 'id' ); + private static function id_matches( string $wanted, array $row, bool $quirks ): bool { + $id = DocumentGenerator::get_attribute_value( $row, 'id' ); if ( ! is_string( $id ) ) { return false; } @@ -180,8 +193,8 @@ private static function id_matches( string $wanted, array $element, bool $quirks : $id === $wanted; } - private static function attr_matches( array $sub, array $element ): bool { - $attr_value = DocumentGenerator::get_attribute_value( $element, $sub['name'] ); + private static function attr_matches( array $sub, array $row ): bool { + $attr_value = DocumentGenerator::get_attribute_value( $row, $sub['name'] ); if ( null === $attr_value ) { return false; } diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 25490e4a90bc2..a7956ce709e11 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -58,7 +58,8 @@ public static function render( Prng $prng, array $list_ast, bool $escape_boost = /** * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). - * @param array|null $model Root element model; enables the path-directed bucket. + * @param array|null $rows Element rows ( TreeCapture shape ) with real + * fids; enables the path-directed bucket. * @return array{ * bucket: string, * selector: string, @@ -69,12 +70,12 @@ public static function render( Prng $prng, array $list_ast, bool $escape_boost = * mustNotMatchFid: string|null, * } */ - public static function generate( Prng $prng, array $pools, ?array $model = null, ?string $bucket = null ): array { + public static function generate( Prng $prng, array $pools, ?array $rows = null, ?string $bucket = null ): array { $generator = new self( $prng, $pools ); if ( null === $bucket ) { $bucket = $prng->weighted( - null === $model + null === $rows || array() === $rows ? array( 'supported-compound' => 30, 'supported-complex' => 25, @@ -95,7 +96,7 @@ public static function generate( Prng $prng, array $pools, ?array $model = null, ); } - if ( 'path-directed' === $bucket && null === $model ) { + if ( 'path-directed' === $bucket && ( null === $rows || array() === $rows ) ) { $bucket = 'supported-complex'; } @@ -121,7 +122,7 @@ public static function generate( Prng $prng, array $pools, ?array $model = null, ); case 'path-directed': - return $generator->gen_path_directed( $model ); + return $generator->gen_path_directed( $rows ); case 'unsupported': return array( @@ -434,26 +435,21 @@ private function pick_name( string $pool_key ): string { * the element ( or, for combinator loosening, still guaranteed to ). */ - private function gen_path_directed( array $model ): array { - $pairs = DocumentGenerator::flatten_with_ancestors( $model ); - + private function gen_path_directed( array $rows ): array { // Bias toward elements deep enough for a meaningful context chain. $deep = array(); - foreach ( $pairs as $pair ) { - if ( count( $pair[1] ) >= 2 ) { - $deep[] = $pair; + foreach ( $rows as $row ) { + if ( count( $row['ancestorTags'] ) >= 2 ) { + $deep[] = $row; } } - if ( array() !== $deep && $this->prng->chance( 75 ) ) { - $pair = $this->prng->choice( $deep ); - } else { - $pair = $this->prng->choice( $pairs ); - } - list( $element, $ancestors ) = $pair; + $element = array() !== $deep && $this->prng->chance( 75 ) + ? $this->prng->choice( $deep ) + : $this->prng->choice( $rows ); $compound = $this->path_compound_for( $element ); - $context = array() !== $ancestors && $this->prng->chance( 75 ) - ? $this->path_context_for( $ancestors ) + $context = array() !== $element['ancestorTags'] && $this->prng->chance( 75 ) + ? $this->path_context_for( $element['ancestorTags'] ) : array(); $list = array( @@ -492,7 +488,7 @@ private function gen_path_directed( array $model ): array { ); } - /** A compound selector built only from features the element really has. */ + /** A compound selector built only from features the element row really has. */ private function path_compound_for( array $element ): array { $tag = ascii_strtolower( $element['tag'] ); @@ -513,7 +509,7 @@ private function path_compound_for( array $element ): array { $seen_attrs = array(); foreach ( $element['attrs'] as $attr ) { $lower = ascii_strtolower( $attr[0] ); - if ( isset( $seen_attrs[ $lower ] ) || 'data-fid' === $lower ) { + if ( isset( $seen_attrs[ $lower ] ) ) { continue; } $seen_attrs[ $lower ] = true; @@ -629,19 +625,19 @@ private function path_attr_feature( string $name, $value ): array { * `>` is only used for the immediately-next ancestor, descendant * combinators may skip generations. * - * @param array $ancestors Nearest-first ancestor elements. + * @param string[] $ancestor_tags Nearest-first ancestor tag names. */ - private function path_context_for( array $ancestors ): array { + private function path_context_for( array $ancestor_tags ): array { $chain = array(); $pos = 0; - $count = count( $ancestors ); + $count = count( $ancestor_tags ); while ( $pos < $count && ( array() === $chain || $this->prng->chance( 45 ) ) ) { $jump = $this->prng->chance( 65 ) ? 0 : $this->prng->int( 0, $count - 1 - $pos ); $at = $pos + $jump; $combinator = ( 0 === $jump && $this->prng->chance( 55 ) ) ? '>' : ' '; - $tag = ascii_strtolower( $ancestors[ $at ]['tag'] ); + $tag = ascii_strtolower( $ancestor_tags[ $at ] ); $type = $this->prng->chance( 12 ) ? '*' : ( $this->prng->chance( 25 ) ? $this->random_case( $tag ) : $tag ); diff --git a/tools/css-selector-fuzz/lib/TreeCapture.php b/tools/css-selector-fuzz/lib/TreeCapture.php new file mode 100644 index 0000000000000..9edc3b0541757 --- /dev/null +++ b/tools/css-selector-fuzz/lib/TreeCapture.php @@ -0,0 +1,114 @@ +, + * ancestorTags: string[] nearest-first ) + * tag row: same without ancestorTags. + */ +class TreeCapture { + + const CAPTURE_ITERATION_LIMIT = 20000; + + /** + * @return array{ + * htmlRows: array|null, + * tagRows: array|null, + * quirks: bool, + * error: string|null, + * } + */ + public static function capture( string $html ): array { + $out = array( + 'htmlRows' => null, + 'tagRows' => null, + 'quirks' => false, + 'error' => null, + ); + + $processor = \WP_HTML_Processor::create_full_parser( $html ); + $rows = array(); + $iterations = 0; + while ( $processor->next_tag() ) { + if ( ++$iterations > self::CAPTURE_ITERATION_LIMIT ) { + $out['error'] = 'html-capture-iteration-limit'; + return $out; + } + $breadcrumbs = $processor->get_breadcrumbs(); + array_pop( $breadcrumbs ); + $rows[] = array( + 'tag' => (string) $processor->get_tag(), + 'fid' => self::fid_of( $processor ), + 'attrs' => self::attrs_of( $processor ), + 'ancestorTags' => array_reverse( $breadcrumbs ), + ); + } + + if ( null !== $processor->get_last_error() ) { + $out['error'] = 'html-processor-error: ' . $processor->get_last_error(); + return $out; + } + if ( null !== $processor->get_unsupported_exception() ) { + $out['error'] = 'html-processor-unsupported: ' . $processor->get_unsupported_exception()->getMessage(); + return $out; + } + + $out['htmlRows'] = $rows; + $out['quirks'] = $processor->is_quirks_mode(); + + $tag_processor = new \WP_HTML_Tag_Processor( $html ); + $tag_rows = array(); + $iterations = 0; + while ( $tag_processor->next_tag() ) { + if ( ++$iterations > self::CAPTURE_ITERATION_LIMIT ) { + $out['error'] = 'tag-capture-iteration-limit'; + return $out; + } + $tag_rows[] = array( + 'tag' => (string) $tag_processor->get_tag(), + 'fid' => self::fid_of( $tag_processor ), + 'attrs' => self::attrs_of( $tag_processor ), + ); + } + $out['tagRows'] = $tag_rows; + + return $out; + } + + /** The element's data-fid, or the same placeholder collect_matches() uses. */ + private static function fid_of( $processor ): string { + $fid = $processor->get_attribute( 'data-fid' ); + return is_string( $fid ) ? $fid : '(missing-fid:' . $processor->get_tag() . ')'; + } + + /** + * All attributes as ( lowercase name, decoded value ) pairs, excluding + * data-fid ( stored separately, mirroring the generated model's shape ). + * + * @return array + */ + private static function attrs_of( $processor ): array { + $attrs = array(); + foreach ( (array) $processor->get_attribute_names_with_prefix( '' ) as $name ) { + if ( 'data-fid' === $name ) { + continue; + } + $value = $processor->get_attribute( $name ); + $attrs[] = array( $name, true === $value ? true : (string) $value ); + } + return $attrs; + } +} diff --git a/tools/css-selector-fuzz/lib/WildDocumentGenerator.php b/tools/css-selector-fuzz/lib/WildDocumentGenerator.php new file mode 100644 index 0000000000000..78ae5a329f162 --- /dev/null +++ b/tools/css-selector-fuzz/lib/WildDocumentGenerator.php @@ -0,0 +1,362 @@ + '', + 'html' => '', + 'legacy-compat' => '', + 'quirky' => '', + 'limited' => '', + ); + + /** @var Prng */ + private $prng; + private $fid_counter = 0; + private $pools; + + private function __construct( Prng $prng ) { + $this->prng = $prng; + $this->pools = array( + 'tags' => array( 'html', 'head', 'body' ), + 'classes' => array(), + 'ids' => array(), + 'attrNames' => array(), + 'attrValues' => array(), + ); + } + + /** + * @return array{model: null, html: string, pools: array, wild: true, doctype: string} + */ + public static function generate( Prng $prng ): array { + $generator = new self( $prng ); + return $generator->build(); + } + + private function build(): array { + $doctype_kind = $this->prng->weighted( + array( + 'none' => 25, + 'html' => 45, + 'legacy-compat' => 10, + 'quirky' => 12, + 'limited' => 8, + ) + ); + + $out = self::DOCTYPES[ $doctype_kind ]; + + if ( $this->prng->chance( 15 ) ) { + $out .= 'render_attrs( $this->random_attrs() ) . '>'; + } + if ( $this->prng->chance( 10 ) ) { + $out .= 'render_attrs( $this->random_attrs() ) . '>'; + } + + $max_elements = $this->prng->int( 4, 35 ); + $token_budget = $this->prng->int( 8, 70 ); + $open = array(); + + for ( $i = 0; $i < $token_budget; $i++ ) { + $in_table = $this->in_table_context( $open ); + + $kind = $this->prng->weighted( + array( + 'start' => 42, + 'void' => $in_table ? 0 : 8, + 'end' => 24, + 'text' => 16, + 'comment' => 5, + 'stray' => $in_table ? 0 : 5, + ) + ); + + switch ( $kind ) { + case 'start': + if ( $this->fid_counter >= $max_elements ) { + break; + } + $tag = $in_table + ? $this->prng->choice( array( 'caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'tr', 'td', 'td', 'th' ) ) + : $this->prng->choice( self::TAGS ); + if ( 'a' === $tag && in_array( 'a', $open, true ) ) { + // A nested
immediately runs the adoption agency. + $tag = 'span'; + } + $this->pools['tags'][] = $tag; + $out .= '<' . $this->maybe_case( $tag ) + . ' data-fid="w' . $this->fid_counter++ . '"' + . $this->render_attrs( $this->random_attrs() ) . '>'; + $open[] = $tag; + break; + + case 'void': + if ( $this->fid_counter >= $max_elements ) { + break; + } + $tag = $this->prng->choice( self::VOID_TAGS ); + $this->pools['tags'][] = $tag; + $out .= '<' . $this->maybe_case( $tag ) + . ' data-fid="w' . $this->fid_counter++ . '"' + . $this->render_attrs( $this->random_attrs() ) + . ( $this->prng->chance( 25 ) ? ' />' : '>' ); + break; + + case 'end': + if ( array() === $open ) { + break; + } + $pick = $this->prng->weighted( + array( + 'top' => 60, + 'random' => 40, + ) + ); + if ( 'top' === $pick ) { + $tag = array_pop( $open ); + } else { + /* + * Close a non-top open element: misnesting. Never + * across a formatting element — the processor only + * supports the trivial adoption-agency cases and + * bails on the rest ( "any other end tag" / + * "common ancestor" / reconstruction-with-rewind ). + */ + $formatting = array( 'a', 'b', 'i', 'em', 'strong', 'u', 's', 'code', 'small' ); + $lowest = count( $open ) - 1; + while ( $lowest > 0 && ! in_array( $open[ $lowest ], $formatting, true ) ) { + $lowest--; + } + if ( in_array( $open[ $lowest ], $formatting, true ) ) { + $lowest++; + } + if ( $lowest > count( $open ) - 1 ) { + $tag = array_pop( $open ); + } else { + $at = $this->prng->int( $lowest, count( $open ) - 1 ); + $tag = $open[ $at ]; + array_splice( $open, $at, 1 ); + } + } + $out .= 'maybe_case( $tag ) . '>'; + break; + + case 'text': + // Non-whitespace text in table context is unsupported + // (pending-table-character-tokens), keep it whitespace. + $out .= $in_table + ? "\n " + : $this->prng->choice( + array( + 'text', + ' wild text ', + "\n", + '& <x>', + 'café ✓', + 'a < b', + ) + ); + break; + + case 'comment': + $out .= ''; + break; + + case 'stray': + // An end tag for something that is not open. + // No formatting tags here: a stray formatting end tag + // runs the adoption agency's unsupported branches. + $out .= 'prng->choice( array( 'div', 'p', 'table', 'tr', 'li', 'span', 'x-wild' ) ) . '>'; + break; + } + } + + // Leave roughly half of the still-open elements unclosed. + foreach ( array_reverse( $open ) as $tag ) { + if ( $this->prng->chance( 50 ) ) { + $out .= 'maybe_case( $tag ) . '>'; + } + } + + foreach ( $this->pools as $key => $values ) { + $this->pools[ $key ] = array_values( array_unique( $values ) ); + } + + return array( + 'model' => null, + 'html' => $out, + 'pools' => $this->pools, + 'wild' => true, + 'doctype' => $doctype_kind, + ); + } + + /** + * Whether the insertion point is in table context outside any cell or + * caption — where arbitrary content would foster-parent (unsupported). + */ + private function in_table_context( array $open ): bool { + for ( $i = count( $open ) - 1; $i >= 0; $i-- ) { + $tag = $open[ $i ]; + if ( in_array( $tag, array( 'td', 'th', 'caption' ), true ) ) { + return false; + } + if ( in_array( $tag, array( 'table', 'thead', 'tbody', 'tfoot', 'tr', 'colgroup' ), true ) ) { + return true; + } + } + return false; + } + + /** @return array */ + private function random_attrs(): array { + $attrs = array(); + $count = $this->prng->weighted( array( 0 => 30, 1 => 35, 2 => 25, 3 => 10 ) ); + + for ( $i = 0; $i < $count; $i++ ) { + $name = $this->prng->choice( DocumentGenerator::ATTR_NAMES ); + + $lower = ascii_strtolower( $name ); + if ( 'class' === $lower ) { + $words = array(); + $n = $this->prng->int( 1, 3 ); + for ( $j = 0; $j < $n; $j++ ) { + $word = $this->random_word(); + $words[] = $word; + $this->pools['classes'][] = $word; + } + $value = implode( ' ', $words ); + } elseif ( 'id' === $lower ) { + $value = $this->random_word(); + $this->pools['ids'][] = $value; + } elseif ( $this->prng->chance( 15 ) ) { + $value = true; + } else { + $value = $this->random_word(); + if ( $this->prng->chance( 20 ) ) { + $value .= ' ' . $this->random_word(); + } + } + + $this->pools['attrNames'][] = $lower; + if ( is_string( $value ) ) { + $this->pools['attrValues'][] = $value; + } + $attrs[] = array( $name, $value ); + } + + return $attrs; + } + + private function render_attrs( array $attrs ): string { + $out = ''; + foreach ( $attrs as $attr ) { + list( $name, $value ) = $attr; + if ( true === $value ) { + $out .= ' ' . $name; + continue; + } + $out .= ' ' . $name . '="' . str_replace( array( '&', '"', '<' ), array( '&', '"', '<' ), $value ) . '"'; + } + return $out; + } + + private function random_word(): string { + $stems = array( 'wild', 'soup', 'alpha', 'beta', 'item', 'note', 'x', 'mixedCase', 'Über', 'main-thing', '--var', '_u' ); + $word = $this->prng->choice( $stems ); + if ( $this->prng->chance( 30 ) ) { + $word .= (string) $this->prng->int( 0, 99 ); + } + return $word; + } + + private function maybe_case( string $tag ): string { + if ( ! $this->prng->chance( 15 ) ) { + return $tag; + } + $out = ''; + for ( $i = 0; $i < strlen( $tag ); $i++ ) { + $c = $tag[ $i ]; + $out .= $this->prng->chance( 50 ) ? strtoupper( $c ) : strtolower( $c ); + } + return $out; + } +} diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 59982794d4aa5..df7e48e8506e2 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -58,9 +58,8 @@ class Worker { public static function run_case( int $seed ): array { Bootstrap::load(); - $prng = new Prng( (string) $seed, 'css-selector-fuzz-case' ); - $document = DocumentGenerator::generate( $prng->fork( 'document' ) ); - $selector = SelectorGenerator::generate( $prng->fork( 'selector' ), $document['pools'], $document['model'] ); + $prng = new Prng( (string) $seed, 'css-selector-fuzz-case' ); + $is_wild = $prng->chance( 30 ); $failures = array(); $record = static function ( string $invariant, array $detail ) use ( &$failures ) { @@ -70,7 +69,70 @@ public static function run_case( int $seed ): array { ); }; - self::check_document_model( $document, $record ); + /* + * The processor's own parse is the matching oracle's ground truth. + * For safe (model-built) documents the model must agree with the + * capture — that soundness check is what lets the capture be trusted + * on wild documents, where no model exists. + * + * Wild documents that hit one of the processor's unsupported + * constructs (it bails on foster parenting, complex adoption-agency + * runs, …) are deterministically regenerated a bounded number of + * times so nearly every wild case carries a usable ground truth. + */ + $document = null; + $capture = null; + $capture_error = null; + $attempts = $is_wild ? 8 : 1; + for ( $attempt = 0; $attempt < $attempts; $attempt++ ) { + $document = $is_wild + ? WildDocumentGenerator::generate( $prng->fork( "wild-document:{$attempt}" ) ) + : DocumentGenerator::generate( $prng->fork( 'document' ) ); + + list( $capture, $capture_error ) = self::guard( + static function () use ( $document ) { + return TreeCapture::capture( $document['html'] ); + } + ); + + if ( null === $capture_error && null === $capture['error'] ) { + break; + } + } + + $rows = null; + $tag_rows = null; + $quirks = false; + + if ( null !== $capture_error ) { + $record( 'model-desync', array( 'phase' => 'capture', 'error' => self::describe_throwable( $capture_error ) ) ); + } elseif ( null !== $capture['error'] ) { + if ( ! $is_wild ) { + $record( 'model-desync', array( 'phase' => 'capture', 'error' => $capture['error'] ) ); + } + // Wild markup the processor cannot fully visit is skipped: + // parsing invariants still run, matching has no ground truth. + } else { + $rows = $capture['htmlRows']; + $tag_rows = $capture['tagRows']; + $quirks = $capture['quirks']; + + if ( ! $is_wild ) { + self::check_capture_against_model( $document, $capture, $record ); + } + } + + $path_rows = null; + if ( null !== $rows ) { + $path_rows = array(); + foreach ( $rows as $row ) { + if ( 0 !== strpos( $row['fid'], '(missing-fid:' ) ) { + $path_rows[] = $row; + } + } + } + + $selector = SelectorGenerator::generate( $prng->fork( 'selector' ), $document['pools'], $path_rows ); $selector_string = $selector['selector']; @@ -183,8 +245,8 @@ static function () use ( $complex_list ) { // --- Match phase --------------------------------------------------- $html_matches = null; - if ( null !== $complex_ast ) { - $expected = ReferenceMatcher::expected_html_processor_matches( $complex_ast, $document['model'], $document['quirks'] ); + if ( null !== $complex_ast && null !== $rows ) { + $expected = ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, $quirks ); /* * Path-directed selectors are guaranteed by construction to match @@ -220,8 +282,8 @@ static function () use ( $complex_list ) { self::check_select_rejection( 'html', $selector_string, $document, $record ); } - if ( null !== $compound_ast ) { - $expected = ReferenceMatcher::expected_tag_processor_matches( $compound_ast, $document['model'] ); + if ( null !== $compound_ast && null !== $tag_rows ) { + $expected = ReferenceMatcher::expected_tag_matches_rows( $compound_ast, $tag_rows ); self::check_select_matches( 'tag', $selector_string, $document, $expected, $record ); } elseif ( null === $compound_list && null === $compound_error ) { self::check_select_rejection( 'tag', $selector_string, $document, $record ); @@ -266,45 +328,38 @@ static function ( $failure ) { } /** - * Verifies that both processors see exactly the modeled element list — - * this guards the oracle itself against renderer/model drift. + * Verifies that the processor's captured view of a safe (model-built) + * document agrees with the generated model — this guards the oracle + * itself against renderer/model drift, and is what justifies trusting + * the capture on wild documents. */ - private static function check_document_model( array $document, callable $record ): void { - $expected = array(); - foreach ( DocumentGenerator::flatten_with_ancestors( $document['model'] ) as $pair ) { - list( $element, $ancestors ) = $pair; - $expected[] = array( - strtoupper( ascii_strtolower( $element['tag'] ) ), - $element['fid'], - count( $ancestors ) + 1, - ); - } - - list( $actual, $error ) = self::guard( - static function () use ( $document ) { - $processor = \WP_HTML_Processor::create_full_parser( $document['html'] ); - $out = array(); - while ( $processor->next_tag() ) { - $fid = $processor->get_attribute( 'data-fid' ); - $out[] = array( - (string) $processor->get_tag(), - is_string( $fid ) ? $fid : '(missing)', - count( $processor->get_breadcrumbs() ), - ); + private static function check_capture_against_model( array $document, array $capture, callable $record ): void { + $model_rows = DocumentGenerator::rows_from_model( $document['model'] ); + + $normalize = static function ( array $rows, bool $with_ancestors ): array { + $out = array(); + foreach ( $rows as $row ) { + $attrs = array(); + foreach ( $row['attrs'] as $attr ) { + $attrs[ $attr[0] ] = $attr[1]; } - if ( null !== $processor->get_last_error() ) { - throw new \RuntimeException( 'Processor error: ' . $processor->get_last_error() ); + ksort( $attrs ); + $normalized = array( + 'tag' => $row['tag'], + 'fid' => $row['fid'], + 'attrs' => $attrs, + ); + if ( $with_ancestors ) { + $normalized['ancestorTags'] = $row['ancestorTags']; } - return $out; + $out[] = $normalized; } - ); - - if ( null !== $error ) { - $record( 'model-desync', array( 'processor' => 'html', 'error' => self::describe_throwable( $error ) ) ); - return; - } + return $out; + }; - if ( $actual !== $expected ) { + $expected = $normalize( $model_rows, true ); + $actual = $normalize( $capture['htmlRows'], true ); + if ( $expected !== $actual ) { $record( 'model-desync', array( @@ -315,33 +370,9 @@ static function () use ( $document ) { ); } - // The tag processor must see the same elements ( without breadcrumbs ). - $expected_tags = array(); - foreach ( $expected as $row ) { - $expected_tags[] = array( $row[0], $row[1] ); - } - - list( $actual_tags, $tag_error ) = self::guard( - static function () use ( $document ) { - $processor = new \WP_HTML_Tag_Processor( $document['html'] ); - $out = array(); - while ( $processor->next_tag() ) { - $fid = $processor->get_attribute( 'data-fid' ); - $out[] = array( - (string) $processor->get_tag(), - is_string( $fid ) ? $fid : '(missing)', - ); - } - return $out; - } - ); - - if ( null !== $tag_error ) { - $record( 'model-desync', array( 'processor' => 'tag', 'error' => self::describe_throwable( $tag_error ) ) ); - return; - } - - if ( $actual_tags !== $expected_tags ) { + $expected_tags = $normalize( $model_rows, false ); + $actual_tags = $normalize( $capture['tagRows'], false ); + if ( $expected_tags !== $actual_tags ) { $record( 'model-desync', array( @@ -351,6 +382,17 @@ static function () use ( $document ) { ) ); } + + if ( $document['quirks'] !== $capture['quirks'] ) { + $record( + 'model-desync', + array( + 'processor' => 'quirks', + 'expected' => $document['quirks'], + 'actual' => $capture['quirks'], + ) + ); + } } /** diff --git a/tools/css-selector-fuzz/lib/autoload.php b/tools/css-selector-fuzz/lib/autoload.php index bc22e73f42f9c..2a26cbdb9f407 100644 --- a/tools/css-selector-fuzz/lib/autoload.php +++ b/tools/css-selector-fuzz/lib/autoload.php @@ -3,6 +3,8 @@ require_once __DIR__ . '/Prng.php'; require_once __DIR__ . '/Bootstrap.php'; require_once __DIR__ . '/DocumentGenerator.php'; +require_once __DIR__ . '/WildDocumentGenerator.php'; +require_once __DIR__ . '/TreeCapture.php'; require_once __DIR__ . '/SelectorGenerator.php'; require_once __DIR__ . '/AstExtractor.php'; require_once __DIR__ . '/ReferenceMatcher.php'; From 37ec7f0a238082ff0daef8d75f13ec5b421288ef Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:34:37 +0200 Subject: [PATCH 160/336] CSS selector fuzz: add lexbor differential oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third, independent matching opinion alongside the WP implementation and the fuzzer's ReferenceMatcher. A batched C harness (lexbor/harness.c, built by lexbor/build.sh against liblexbor pinned to v3.0.0) reads {html, selector} cases over a persistent pipe and answers with lexbor's element tree and matched data-fid sets; the worker auto-detects it. The differential runs on no-quirks documents whose selector parsed, is gated on WP and lexbor agreeing on the element tree (multiset of fid/tag/ancestry — isolating the selector layer from tree construction), and compares match-fid multisets (lexbor reports in document order, WP in visit order). Verdict: lexbor != reference is a fuzzer-oracle problem (lexbor-divergence); reference == lexbor != WP is a high-confidence WP finding (existing match-mismatch-html with no divergence on the case). Confirmed on known Bug 2: reference and lexbor both return the empty set for [x^=""] where WP matches elements. lexbor quirks compensated for (all candidate upstream reports): - #368, open at v3.0.0: class/#id match case-insensitively even in no-quirks mode. Detected by startup probe; lexbor is compared against the reference run with class/ID folding, and quirks documents are excluded from the differential. - Uppercase I/S attribute modifiers are rejected; the non-ASCII ident-codepoint table omits U+00B7 and U+00C0-U+00F6. Both sidestepped by handing lexbor a deterministic canonical re-render of the verified AST (lowercase modifiers, non-ASCII hex-escaped) — byte-level parsing is already covered by AST round-trip and metamorphic invariants. - One callback per matching list branch: LXB_SELECTORS_OPT_MATCH_FIRST. 800 seeds against patched core: 408 compared, 0 divergences. --- tools/css-selector-fuzz/README.md | 34 +++ tools/css-selector-fuzz/lexbor/build.sh | 42 +++ tools/css-selector-fuzz/lexbor/harness.c | 267 ++++++++++++++++++ tools/css-selector-fuzz/lib/LexborOracle.php | 191 +++++++++++++ tools/css-selector-fuzz/lib/Metamorph.php | 17 +- .../lib/SelectorGenerator.php | 100 +++++++ tools/css-selector-fuzz/lib/Worker.php | 116 ++++++++ tools/css-selector-fuzz/lib/autoload.php | 1 + tools/css-selector-fuzz/lib/util.php | 15 + 9 files changed, 767 insertions(+), 16 deletions(-) create mode 100644 tools/css-selector-fuzz/lexbor/build.sh create mode 100644 tools/css-selector-fuzz/lexbor/harness.c create mode 100644 tools/css-selector-fuzz/lib/LexborOracle.php diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 48d5192927a59..3f73c562a6857 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -64,8 +64,42 @@ produces the same document, the same selector, and the same verdict. explicit `*` for an omitted type, and selector-list branch duplication. Skipped for ASTs containing invalid UTF-8 (reachable only from chaos/mutated inputs), which the renderer cannot round-trip. + - lexbor differential (third, independent oracle; requires the harness — + see below): on no-quirks documents whose selector parsed, a canonical + re-render of the verified AST is matched by liblexbor and compared, + as a multiset of fids, against the reference matcher. Gated on WP and + lexbor building the same element tree (fid/tag/ancestry), so it tests + the selector layer, not tree construction. Verdicts: `lexbor-divergence` + (lexbor ≠ reference) is a fuzzer-oracle problem; `match-mismatch-html` + with no accompanying divergence means reference == lexbor ≠ WP — a + high-confidence WP finding. - Repeating a case yields a byte-identical result digest (determinism). +## lexbor harness + +Build with `sh tools/css-selector-fuzz/lexbor/build.sh` (clones and builds +liblexbor, pinned to v3.0.0 = `2ae88a1c6b52`). The worker auto-detects the +binary at `tools/css-selector-fuzz/lexbor/harness` and reports per-batch +tallies (`compared` / `tree-gated` / `skipped-quirks` / `off`). + +Known lexbor issues compensated for at this pin: + +- [#368](https://github.com/lexbor/lexbor/issues/368) (open at v3.0.0): + class and `#id` selectors match ASCII case-insensitively even in + no-quirks documents (`[id=…]` attribute matching is correctly + case-sensitive). Detected by a startup probe; when present, lexbor is + compared against the reference matcher run with quirks-style class/ID + folding, and quirks-mode documents are excluded from the differential + entirely (the reference matcher is the sole quirks authority). +- lexbor rejects uppercase `I`/`S` attribute-selector modifiers, and its + non-ASCII ident-codepoint table omits U+00B7 and U+00C0–U+00F6 (it + starts at U+00F8), rejecting e.g. `.Über` while accepting `.über`. + Both sidestepped by the canonical re-render (lowercase modifiers, all + non-ASCII hex-escaped); both are candidate upstream reports, not WP + findings. +- `lxb_selectors_find` reports a node once per matching selector-list + branch; `LXB_SELECTORS_OPT_MATCH_FIRST` dedupes. + ## Usage Bounded fuzz run (process-isolated chunks, crash/hang attribution): diff --git a/tools/css-selector-fuzz/lexbor/build.sh b/tools/css-selector-fuzz/lexbor/build.sh new file mode 100644 index 0000000000000..186a7e5b703b6 --- /dev/null +++ b/tools/css-selector-fuzz/lexbor/build.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# +# Builds the lexbor differential harness. +# +# Pinned lexbor version: v3.0.0 (2ae88a1c6b5261830eff73ee12bb3cdf805f3cfe). +# Note: lexbor issue #368 ("Class/ID selectors are ASCII case-insensitive +# even in no-quirks mode") is still OPEN at this version; the PHP adapter +# detects it at startup and compensates (see LexborOracle.php). +# +# Usage: +# sh tools/css-selector-fuzz/lexbor/build.sh [lexbor-src-dir] +# +# Produces tools/css-selector-fuzz/lexbor/harness. + +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +SRC="${1:-/tmp/lexbor-src}" +PIN="2ae88a1c6b5261830eff73ee12bb3cdf805f3cfe" + +if [ ! -d "$SRC" ]; then + echo "Cloning lexbor into $SRC ..." + git clone https://github.com/lexbor/lexbor "$SRC" +fi + +git -C "$SRC" checkout --quiet "$PIN" + +if [ ! -f "$SRC/build/liblexbor_static.a" ]; then + echo "Building liblexbor_static ..." + mkdir -p "$SRC/build" + cd "$SRC/build" + cmake -DCMAKE_BUILD_TYPE=Release -DLEXBOR_BUILD_SHARED=OFF \ + -DLEXBOR_BUILD_STATIC=ON -DLEXBOR_BUILD_TESTS=OFF \ + -DLEXBOR_BUILD_EXAMPLES=OFF .. > /dev/null + make -j8 lexbor_static > /dev/null + cd "$HERE" +fi + +cc -O2 -Wall -Wextra -o "$HERE/harness" "$HERE/harness.c" \ + -I "$SRC/source" "$SRC/build/liblexbor_static.a" + +echo "Built $HERE/harness (lexbor $PIN)" diff --git a/tools/css-selector-fuzz/lexbor/harness.c b/tools/css-selector-fuzz/lexbor/harness.c new file mode 100644 index 0000000000000..a33a198090ddf --- /dev/null +++ b/tools/css-selector-fuzz/lexbor/harness.c @@ -0,0 +1,267 @@ +/* + * lexbor differential harness for the CSS selector fuzzer. + * + * Reads one case per line from stdin: + * + * base64(html) "\t" base64(selector) "\n" + * + * For each case, parses the HTML with lexbor, parses the selector with the + * lexbor CSS selectors module, runs lxb_selectors_find over the whole + * document, and emits: + * + * R "\t" TAG "\t" FID "\t" ANC1,ANC2,... one per element, document + * pre-order; ancestors are + * nearest-first uppercase tags + * M "\t" FID one per match, in find order + * X "\t" parse selector did not parse + * X "\t" html html did not parse + * D end of case (then flush) + * + * FID is the element's data-fid attribute value, or "(missing-fid:TAG)" + * for elements without one (matching the fuzzer's placeholder convention). + * Tags are ASCII-uppercased. + * + * Build: see build.sh next to this file. Pinned lexbor version recorded + * there and in the fuzzer README. + */ + +#include +#include +#include + +#include +#include +#include + +#define MAX_DEPTH 512 + +static unsigned char * +b64_decode(const char *in, size_t in_len, size_t *out_len) +{ + static const signed char table[256] = { + ['A'] = 0, ['B'] = 1, ['C'] = 2, ['D'] = 3, ['E'] = 4, + ['F'] = 5, ['G'] = 6, ['H'] = 7, ['I'] = 8, ['J'] = 9, + ['K'] = 10, ['L'] = 11, ['M'] = 12, ['N'] = 13, ['O'] = 14, + ['P'] = 15, ['Q'] = 16, ['R'] = 17, ['S'] = 18, ['T'] = 19, + ['U'] = 20, ['V'] = 21, ['W'] = 22, ['X'] = 23, ['Y'] = 24, + ['Z'] = 25, ['a'] = 26, ['b'] = 27, ['c'] = 28, ['d'] = 29, + ['e'] = 30, ['f'] = 31, ['g'] = 32, ['h'] = 33, ['i'] = 34, + ['j'] = 35, ['k'] = 36, ['l'] = 37, ['m'] = 38, ['n'] = 39, + ['o'] = 40, ['p'] = 41, ['q'] = 42, ['r'] = 43, ['s'] = 44, + ['t'] = 45, ['u'] = 46, ['v'] = 47, ['w'] = 48, ['x'] = 49, + ['y'] = 50, ['z'] = 51, ['0'] = 52, ['1'] = 53, ['2'] = 54, + ['3'] = 55, ['4'] = 56, ['5'] = 57, ['6'] = 58, ['7'] = 59, + ['8'] = 60, ['9'] = 61, ['+'] = 62, ['/'] = 63, + }; + + unsigned char *out = malloc(in_len / 4 * 3 + 4); + size_t o = 0; + unsigned int acc = 0; + int bits = 0; + + if (out == NULL) { + return NULL; + } + + for (size_t i = 0; i < in_len; i++) { + unsigned char c = (unsigned char) in[i]; + if (c == '=' || c == '\n' || c == '\r') { + continue; + } + if (c != 'A' && table[c] == 0 && c != 'A') { + if (c != 'A') { + /* invalid chars are skipped; base64 here is machine-made */ + } + } + acc = (acc << 6) | (unsigned int) table[c]; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[o++] = (unsigned char) ((acc >> bits) & 0xFF); + } + } + + *out_len = o; + return out; +} + +static void +put_upper(const lxb_char_t *name, size_t len) +{ + for (size_t i = 0; i < len; i++) { + unsigned char c = name[i]; + if (c >= 'a' && c <= 'z') { + c = (unsigned char) (c - 'a' + 'A'); + } + putchar(c); + } +} + +static void +put_fid(lxb_dom_node_t *node) +{ + lxb_dom_element_t *element = lxb_dom_interface_element(node); + size_t value_len = 0; + const lxb_char_t *value = lxb_dom_element_get_attribute( + element, (const lxb_char_t *) "data-fid", 8, &value_len); + + if (value != NULL) { + fwrite(value, 1, value_len, stdout); + return; + } + + size_t name_len = 0; + const lxb_char_t *name = lxb_dom_element_qualified_name(element, &name_len); + fputs("(missing-fid:", stdout); + put_upper(name, name_len); + putchar(')'); +} + +struct walk_state { + const lxb_char_t *stack[MAX_DEPTH]; /* uppercase emitted on the fly */ + size_t stack_len[MAX_DEPTH]; + int depth; +}; + +static void +walk(lxb_dom_node_t *node, struct walk_state *state) +{ + for (lxb_dom_node_t *child = node->first_child; child != NULL; + child = child->next) { + if (child->type != LXB_DOM_NODE_TYPE_ELEMENT) { + continue; + } + + size_t name_len = 0; + const lxb_char_t *name = lxb_dom_element_qualified_name( + lxb_dom_interface_element(child), &name_len); + + fputs("R\t", stdout); + put_upper(name, name_len); + putchar('\t'); + put_fid(child); + putchar('\t'); + for (int i = state->depth - 1; i >= 0; i--) { + put_upper(state->stack[i], state->stack_len[i]); + if (i > 0) { + putchar(','); + } + } + putchar('\n'); + + if (state->depth < MAX_DEPTH) { + state->stack[state->depth] = name; + state->stack_len[state->depth] = name_len; + state->depth++; + walk(child, state); + state->depth--; + } + } +} + +static lxb_status_t +find_callback(lxb_dom_node_t *node, lxb_css_selector_specificity_t spec, + void *ctx) +{ + (void) spec; + (void) ctx; + fputs("M\t", stdout); + put_fid(node); + putchar('\n'); + return LXB_STATUS_OK; +} + +int +main(void) +{ + char *line = NULL; + size_t line_cap = 0; + ssize_t line_len; + + while ((line_len = getline(&line, &line_cap, stdin)) > 0) { + char *tab = memchr(line, '\t', (size_t) line_len); + if (tab == NULL) { + fputs("X\tprotocol\nD\n", stdout); + fflush(stdout); + continue; + } + + size_t html_len = 0; + size_t selector_len = 0; + unsigned char *html = b64_decode(line, (size_t) (tab - line), &html_len); + unsigned char *selector = b64_decode( + tab + 1, (size_t) (line + line_len - tab - 1), &selector_len); + + if (html == NULL || selector == NULL) { + fputs("X\tprotocol\nD\n", stdout); + fflush(stdout); + free(html); + free(selector); + continue; + } + + lxb_html_document_t *document = lxb_html_document_create(); + if (lxb_html_document_parse(document, html, html_len) + != LXB_STATUS_OK) { + fputs("X\thtml\nD\n", stdout); + fflush(stdout); + lxb_html_document_destroy(document); + free(html); + free(selector); + continue; + } + + struct walk_state state = { .depth = 0 }; + walk(lxb_dom_interface_node(document), &state); + + /* + * Parser and selectors engine are created per case: + * lxb_css_selector_list_destroy_memory() releases the parser's + * whole arena, so reuse across cases is unsafe. + */ + lxb_css_parser_t *parser = lxb_css_parser_create(); + lxb_selectors_t *selectors = lxb_selectors_create(); + if (lxb_css_parser_init(parser, NULL) != LXB_STATUS_OK + || lxb_selectors_init(selectors) != LXB_STATUS_OK) { + fputs("X\tinit\nD\n", stdout); + fflush(stdout); + lxb_selectors_destroy(selectors, true); + lxb_css_parser_destroy(parser, true); + lxb_html_document_destroy(document); + free(html); + free(selector); + continue; + } + + /* Report each node once even when several list branches match. */ + lxb_selectors_opt_set(selectors, LXB_SELECTORS_OPT_MATCH_FIRST); + + lxb_css_selector_list_t *list = lxb_css_selectors_parse( + parser, selector, selector_len); + + if (parser->status != LXB_STATUS_OK || list == NULL) { + fputs("X\tparse\n", stdout); + } + else { + lxb_status_t status = lxb_selectors_find( + selectors, lxb_dom_interface_node(document), list, + find_callback, NULL); + if (status != LXB_STATUS_OK) { + fputs("X\tfind\n", stdout); + } + lxb_css_selector_list_destroy_memory(list); + } + + fputs("D\n", stdout); + fflush(stdout); + + lxb_selectors_destroy(selectors, true); + lxb_css_parser_destroy(parser, true); + lxb_html_document_destroy(document); + free(html); + free(selector); + } + + free(line); + return EXIT_SUCCESS; +} diff --git a/tools/css-selector-fuzz/lib/LexborOracle.php b/tools/css-selector-fuzz/lib/LexborOracle.php new file mode 100644 index 0000000000000..a7a6864aa62ab --- /dev/null +++ b/tools/css-selector-fuzz/lib/LexborOracle.php @@ -0,0 +1,191 @@ + fuzzer-oracle problem ( investigate + * the fuzzer, 'lexbor-divergence' ). + * reference == lexbor != WP => high-confidence WP finding ( the + * regular match-mismatch-html failure + * with no accompanying divergence ). + * + * Known bug compensated for: lexbor #368 — class and #id selectors match + * ASCII case-insensitively even in no-quirks mode ( attribute selectors + * like [id=x] are correctly case-sensitive ). Detected by probe at startup; + * when present, lexbor is compared against the reference matcher run with + * quirks-style class/ID folding. Open at the pinned v3.0.0. + */ +class LexborOracle { + + const READ_TIMEOUT_SECONDS = 5; + + /** @var resource|null */ + private static $process = null; + /** @var array|null */ + private static $pipes = null; + /** @var bool|null */ + private static $available = null; + /** @var bool */ + private static $issue368 = false; + + public static function harness_path(): string { + return dirname( __DIR__ ) . '/lexbor/harness'; + } + + /** Whether the harness is built, starts, and answered the probes. */ + public static function available(): bool { + if ( null !== self::$available ) { + return self::$available; + } + + self::$available = false; + if ( ! is_executable( self::harness_path() ) || ! self::start() ) { + return false; + } + + // Probe: sanity plus issue-#368 detection. + $sane = self::query( '
', 'div.a' ); + if ( null === $sane || array( 'x' ) !== $sane['matches'] ) { + self::stop(); + return false; + } + + $folded = self::query( '
', '.A' ); + self::$issue368 = null !== $folded && array( 'x' ) === $folded['matches']; + self::$available = true; + return true; + } + + /** Whether the pinned lexbor exhibits issue #368 ( class/ID case folding ). */ + public static function has_issue_368(): bool { + return self::$issue368; + } + + /** + * Runs one case through lexbor. + * + * @return array{ + * rows: array, + * matches: string[], + * error: string|null, + * }|null Null when the harness is unavailable or misbehaved ( the + * harness is stopped; the caller should skip the differential ). + */ + public static function query( string $html, string $selector ): ?array { + if ( null === self::$process && ! self::start() ) { + return null; + } + + $line = base64_encode( $html ) . "\t" . base64_encode( $selector ) . "\n"; + $written = fwrite( self::$pipes[0], $line ); + fflush( self::$pipes[0] ); + if ( strlen( $line ) !== $written ) { + self::stop(); + self::$available = false; + return null; + } + + $rows = array(); + $matches = array(); + $error = null; + + while ( true ) { + $response = self::read_line(); + if ( null === $response ) { + self::stop(); + self::$available = false; + return null; + } + if ( 'D' === $response ) { + break; + } + + $parts = explode( "\t", $response ); + switch ( $parts[0] ) { + case 'R': + $rows[] = array( + 'tag' => $parts[1] ?? '', + 'fid' => $parts[2] ?? '', + 'ancestorTags' => '' === ( $parts[3] ?? '' ) ? array() : explode( ',', $parts[3] ), + ); + break; + case 'M': + $matches[] = $parts[1] ?? ''; + break; + case 'X': + $error = $parts[1] ?? 'unknown'; + break; + } + } + + return array( + 'rows' => $rows, + 'matches' => $matches, + 'error' => $error, + ); + } + + private static function start(): bool { + $descriptors = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'file', '/dev/null', 'w' ), + ); + + $process = proc_open( array( self::harness_path() ), $descriptors, $pipes ); + if ( ! is_resource( $process ) ) { + return false; + } + + self::$process = $process; + self::$pipes = $pipes; + stream_set_blocking( $pipes[1], false ); + return true; + } + + private static function stop(): void { + if ( null === self::$process ) { + return; + } + @fclose( self::$pipes[0] ); + @fclose( self::$pipes[1] ); + @proc_terminate( self::$process, 9 ); + @proc_close( self::$process ); + self::$process = null; + self::$pipes = null; + } + + /** Reads one newline-terminated line with a timeout; null on failure. */ + private static function read_line(): ?string { + $line = ''; + $deadline = microtime( true ) + self::READ_TIMEOUT_SECONDS; + + while ( true ) { + $read = array( self::$pipes[1] ); + $write = null; + $except = null; + $left = $deadline - microtime( true ); + if ( $left <= 0 ) { + return null; + } + $ready = stream_select( $read, $write, $except, 0, (int) ( $left * 1e6 ) ); + if ( false === $ready || 0 === $ready ) { + return null; + } + $chunk = fgets( self::$pipes[1] ); + if ( false === $chunk ) { + return null; + } + $line .= $chunk; + if ( str_ends_with( $line, "\n" ) ) { + return substr( $line, 0, -1 ); + } + } + } +} diff --git a/tools/css-selector-fuzz/lib/Metamorph.php b/tools/css-selector-fuzz/lib/Metamorph.php index 017b65fdd09f5..44e0e434ff4b2 100644 --- a/tools/css-selector-fuzz/lib/Metamorph.php +++ b/tools/css-selector-fuzz/lib/Metamorph.php @@ -41,7 +41,7 @@ public static function variants( array $list_ast, Prng $prng ): array { * UTF-8 names, so such ASTs (only reachable from chaos/mutated * inputs) are not transformable. */ - if ( ! self::ast_strings_are_utf8( $list_ast ) ) { + if ( ! ast_strings_are_utf8( $list_ast ) ) { return array(); } @@ -109,21 +109,6 @@ static function ( string $type ) use ( $prng ): string { return $out; } - /** Whether every string anywhere in the AST is valid UTF-8. */ - private static function ast_strings_are_utf8( $node ): bool { - if ( is_string( $node ) ) { - return (bool) preg_match( '//u', $node ); - } - if ( is_array( $node ) ) { - foreach ( $node as $child ) { - if ( ! self::ast_strings_are_utf8( $child ) ) { - return false; - } - } - } - return true; - } - /** Applies $fn to every type-selector name: compound types and context types. */ private static function map_types( array $list_ast, callable $fn ): array { foreach ( $list_ast as &$complex ) { diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index a7956ce709e11..131c3e970cefb 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -56,6 +56,106 @@ public static function render( Prng $prng, array $list_ast, bool $escape_boost = return $generator->render_complex_list( $list_ast ); } + /** + * Renders a canonical complex-list AST deterministically with minimal + * escaping: single spaces around combinators, `, ` between branches, + * double-quoted attribute values, lowercase `i`/`s` modifiers, and all + * non-ASCII codepoints hex-escaped. Used to hand a semantically-identical + * selector to external engines: lexbor rejects some byte-level forms WP + * correctly accepts ( uppercase I/S attribute modifiers; raw non-ASCII + * ident codepoints in U+00B7, U+00C0-U+00F6 — its non-ASCII ident table + * starts at U+00F8 ). Escaping sidesteps codepoint classification. + */ + public static function render_canonical( array $list_ast ): string { + $branches = array(); + foreach ( $list_ast as $complex ) { + $out = ''; + foreach ( array_reverse( $complex['context'] ) as $pair ) { + list( $type, $combinator ) = $pair; + $out .= '*' === $type ? '*' : self::canonical_ident( $type ); + $out .= '>' === $combinator ? ' > ' : ' '; + } + + $compound = $complex['self']; + if ( null !== $compound['type'] ) { + $out .= '*' === $compound['type'] ? '*' : self::canonical_ident( $compound['type'] ); + } + foreach ( (array) $compound['subs'] as $sub ) { + switch ( $sub['kind'] ) { + case 'class': + $out .= '.' . self::canonical_ident( $sub['name'] ); + break; + case 'id': + $out .= '#' . self::canonical_ident( $sub['name'] ); + break; + case 'attr': + $out .= '[' . self::canonical_ident( $sub['name'] ); + if ( null !== $sub['matcher'] ) { + $matchers = array( + 'exact' => '=', + 'one-of' => '~=', + 'exact-or-hyphen-suffixed' => '|=', + 'prefixed' => '^=', + 'suffixed' => '$=', + 'contains' => '*=', + ); + $out .= $matchers[ $sub['matcher'] ] . self::canonical_string( (string) $sub['value'] ); + if ( 'case-insensitive' === $sub['modifier'] ) { + $out .= ' i'; + } elseif ( 'case-sensitive' === $sub['modifier'] ) { + $out .= ' s'; + } + } + $out .= ']'; + break; + } + } + $branches[] = $out; + } + return implode( ', ', $branches ); + } + + private static function canonical_ident( string $name ): string { + $points = utf8_codepoints( $name ); + $count = count( $points ); + $out = ''; + + foreach ( $points as $i => $point ) { + list( $char, $cp ) = $point; + + $is_digit = $cp >= 0x30 && $cp <= 0x39; + $is_ident_char = ( + '-' === $char || + '_' === $char || + $is_digit || + ( $cp >= 0x41 && $cp <= 0x5A ) || + ( $cp >= 0x61 && $cp <= 0x7A ) + ); + + $must_escape = ! $is_ident_char + || ( 0 === $i && $is_digit ) + || ( 1 === $i && '-' === $points[0][0] && $is_digit ) + || ( 1 === $count && '-' === $char ); + + $out .= $must_escape ? '\\' . dechex( $cp ) . ' ' : $char; + } + + return $out; + } + + private static function canonical_string( string $value ): string { + $out = '"'; + foreach ( utf8_codepoints( $value ) as $point ) { + list( $char, $cp ) = $point; + if ( '"' === $char || '\\' === $char || $cp < 0x20 || $cp > 0x7E ) { + $out .= '\\' . dechex( $cp ) . ' '; + } else { + $out .= $char; + } + } + return $out . '"'; + } + /** * @param array $pools Pools from DocumentGenerator ( tags, classes, ids, attrNames, attrValues ). * @param array|null $rows Element rows ( TreeCapture shape ) with real diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index df7e48e8506e2..49b79fd39241a 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -245,6 +245,7 @@ static function () use ( $complex_list ) { // --- Match phase --------------------------------------------------- $html_matches = null; + $lexbor_state = 'off'; if ( null !== $complex_ast && null !== $rows ) { $expected = ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, $quirks ); @@ -278,6 +279,8 @@ static function () use ( $complex_list ) { } $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); + + $lexbor_state = self::check_lexbor_differential( $complex_ast, $selector_string, $document, $rows, $quirks, $expected, $record ); } elseif ( null === $complex_list && null === $complex_error ) { self::check_select_rejection( 'html', $selector_string, $document, $record ); } @@ -324,6 +327,7 @@ static function ( $failure ) { 'failures' => $failures, 'selector' => $selector_string, 'html' => $document['html'], + 'lexbor' => $lexbor_state, ); } @@ -479,6 +483,115 @@ private static function check_select_matches( string $target, string $selector_s return $actual; } + /** + * Runs the lexbor differential — the THIRD, independent matching opinion. + * + * Quirks-mode documents are excluded ( lexbor #368 makes its quirks + * behavior untrustworthy and WP's quirks class/ID folding is owned by + * ReferenceMatcher ). The comparison only runs when lexbor built the + * same element tree as WP ( fid/tag/ancestry multiset ), so it tests + * the selector layer, not tree construction. + * + * Verdict triage: + * - 'lexbor-divergence' lexbor != reference: a fuzzer-oracle problem + * ( or an un-compensated lexbor bug ) — never a + * WP verdict on its own. + * - 'lexbor-parse-reject' lexbor refused a selector WP accepted. + * - match-mismatch-html with NO lexbor-divergence on the same case + * means reference == lexbor != WP: a + * high-confidence WP finding. + * + * @return string Tally state: off|skipped-quirks|error|tree-gated|compared. + */ + private static function check_lexbor_differential( array $complex_ast, string $selector_string, array $document, array $rows, bool $quirks, array $expected, callable $record ): string { + if ( ! LexborOracle::available() ) { + return 'off'; + } + if ( $quirks ) { + return 'skipped-quirks'; + } + + /* + * lexbor receives a canonical re-render of the (already verified) + * AST rather than the original byte form: the differential targets + * matching semantics, while byte-level parsing (escapes, whitespace, + * modifier case — lexbor e.g. rejects uppercase I/S modifiers) is + * covered by the AST round-trip and metamorphic invariants. ASTs + * containing invalid UTF-8 cannot be re-rendered and are skipped. + */ + if ( ! ast_strings_are_utf8( $complex_ast ) ) { + return 'skipped-utf8'; + } + $canonical = SelectorGenerator::render_canonical( $complex_ast ); + + $lex = LexborOracle::query( $document['html'], $canonical ); + if ( null === $lex ) { + return 'error'; + } + + if ( 'parse' === $lex['error'] ) { + $record( + 'lexbor-parse-reject', + array( + 'note' => 'lexbor rejected the canonical form of a selector the WP parser accepted', + 'canonical' => printable_bytes( $canonical ), + ) + ); + return 'compared'; + } + if ( null !== $lex['error'] ) { + return 'error'; + } + + if ( ! self::trees_agree( $rows, $lex['rows'] ) ) { + return 'tree-gated'; + } + + /* + * lexbor #368: class/#id match ASCII case-insensitively even in + * no-quirks documents. Compare lexbor against the reference run + * with quirks-style class/ID folding ( the only thing the flag + * affects ) so the rest of the semantics still get differential + * coverage; WP itself is still held to the strict expectation. + */ + $expected_for_lexbor = LexborOracle::has_issue_368() + ? ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, true ) + : $expected; + + // lexbor reports in document order, WP/reference in visit order — + // compare as multisets. + $lex_matches = $lex['matches']; + sort( $lex_matches ); + sort( $expected_for_lexbor ); + + if ( $lex_matches !== $expected_for_lexbor ) { + $record( + 'lexbor-divergence', + array( + 'reference' => $expected_for_lexbor, + 'lexbor' => $lex_matches, + 'issue368' => LexborOracle::has_issue_368(), + ) + ); + } + + return 'compared'; + } + + /** Multiset equality of ( tag, fid, ancestry ) between WP and lexbor rows. */ + private static function trees_agree( array $wp_rows, array $lexbor_rows ): bool { + $serialize = static function ( array $rows ): array { + $out = array(); + foreach ( $rows as $row ) { + $out[] = $row['tag'] . '|' . $row['fid'] . '|' . implode( ',', $row['ancestorTags'] ); + } + sort( $out ); + return $out; + }; + + return $serialize( $wp_rows ) === $serialize( $lexbor_rows ); + } + /** * Checks the metamorphic relations: each meaning-preserving transform of * the parsed selector must parse, must (for AST-preserving transforms) @@ -660,6 +773,7 @@ public static function run_batch( array $options ): array { $failures = 0; $buckets = array(); $signatures = array(); + $lexbor = array(); $last_seed = null; $stop_reason = 'completed'; @@ -688,6 +802,7 @@ public static function run_batch( array $options ): array { } $buckets[ $result['bucket'] ] = ( $buckets[ $result['bucket'] ] ?? 0 ) + 1; + $lexbor[ $result['lexbor'] ] = ( $lexbor[ $result['lexbor'] ] ?? 0 ) + 1; $last_seed = $seed; foreach ( $result['failures'] as $failure ) { @@ -722,6 +837,7 @@ public static function run_batch( array $options ): array { 'failures' => $failures, 'buckets' => $buckets, 'signatures' => $signatures, + 'lexbor' => $lexbor, 'stopReason' => $stop_reason, 'durationMs' => (int) round( 1000 * ( microtime( true ) - $started_at ) ), ); diff --git a/tools/css-selector-fuzz/lib/autoload.php b/tools/css-selector-fuzz/lib/autoload.php index 2a26cbdb9f407..6ebdcbc75d6c3 100644 --- a/tools/css-selector-fuzz/lib/autoload.php +++ b/tools/css-selector-fuzz/lib/autoload.php @@ -9,4 +9,5 @@ require_once __DIR__ . '/AstExtractor.php'; require_once __DIR__ . '/ReferenceMatcher.php'; require_once __DIR__ . '/Metamorph.php'; +require_once __DIR__ . '/LexborOracle.php'; require_once __DIR__ . '/Worker.php'; diff --git a/tools/css-selector-fuzz/lib/util.php b/tools/css-selector-fuzz/lib/util.php index 01e7dc20b7a73..6fe6662a6a3b8 100644 --- a/tools/css-selector-fuzz/lib/util.php +++ b/tools/css-selector-fuzz/lib/util.php @@ -118,6 +118,21 @@ function git_metadata(): array { ); } +/** Whether every string anywhere in a nested array is valid UTF-8. */ +function ast_strings_are_utf8( $node ): bool { + if ( is_string( $node ) ) { + return (bool) preg_match( '//u', $node ); + } + if ( is_array( $node ) ) { + foreach ( $node as $child ) { + if ( ! ast_strings_are_utf8( $child ) ) { + return false; + } + } + } + return true; +} + function ascii_strtolower( string $input ): string { return strtr( $input, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' ); } From 7b3bbcf032b31bb7f154b98c3ae0d25036cf8763 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:46:11 +0200 Subject: [PATCH 161/336] CSS selector fuzz: coverage measurement + unreachable-branch generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage.php (phpdbg opcode log, chunked to bound memory) reporting line coverage of src/wp-includes/html-api/css/. Add the edge-escape selector bucket to reach branches the structural generators cannot: hex escapes for NUL / surrogate / over-max codepoints (decode to U+FFFD) and raw NUL / CR / CRLF / FF in the selector input (normalize_selector_input). Two invalid templates ([ a, [a="x\) added for attribute/string parse guards. Result: 376/401 lines (93.8%); 388/401 = 96.8% of reachable code. The 25 unreached lines are itemized in COVERAGE.md: 12 are a phpdbg case-label artifact (bodies verified executing), 13 are defensive guards the public API cannot reach (e.g. the match-time unsupported-combinator arm — the parser only emits ' ' and '>'). --- tools/css-selector-fuzz/COVERAGE.md | 75 ++++++++++ tools/css-selector-fuzz/README.md | 9 ++ tools/css-selector-fuzz/coverage.php | 92 ++++++++++++ .../lib/SelectorGenerator.php | 140 ++++++++++++++++-- 4 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 tools/css-selector-fuzz/COVERAGE.md create mode 100644 tools/css-selector-fuzz/coverage.php diff --git a/tools/css-selector-fuzz/COVERAGE.md b/tools/css-selector-fuzz/COVERAGE.md new file mode 100644 index 0000000000000..e69e34cf38a17 --- /dev/null +++ b/tools/css-selector-fuzz/COVERAGE.md @@ -0,0 +1,75 @@ +# CSS Selector Fuzzer — Coverage Report + +Line coverage of `src/wp-includes/html-api/css/` under the fuzzer, measured +with phpdbg's opcode log over 3000 deterministic seeds: + + phpdbg -qrr tools/css-selector-fuzz/coverage.php --seeds 3000 --list-uncovered + +| file | covered / executable | % | +|---|---|---| +| class-wp-css-attribute-selector.php | 102 / 112 | 91.1% | +| class-wp-css-class-selector.php | 10 / 10 | 100% | +| class-wp-css-complex-selector-list.php | 16 / 16 | 100% | +| class-wp-css-complex-selector.php | 59 / 66 | 89.4% | +| class-wp-css-compound-selector-list.php | 27 / 28 | 96.4% | +| class-wp-css-compound-selector.php | 29 / 32 | 90.6% | +| class-wp-css-id-selector.php | 12 / 12 | 100% | +| class-wp-css-selector-parser-matcher.php | 106 / 108 | 98.1% | +| class-wp-css-type-selector.php | 15 / 17 | 88.2% | +| **TOTAL** | **376 / 401** | **93.8%** | + +The 25 unreached lines are all accounted for below. Twelve are a phpdbg +measurement artifact (the code executes); the other thirteen are defensive +guards that the public entry points cannot reach. Effective coverage of +reachable code is **388 / 401 = 96.8%**. + +## phpdbg `case`-label artifact (12 lines — code executes) + +phpdbg attributes a `switch` arm's execution to the body line, not the bare +`case X:` label line. The fuzzer exercises every one of these arms (verified +directly: the body line immediately after each label is covered, and the +lexbor differential + self-check confirm the corresponding behavior). These +are not real gaps: + +- `class-wp-css-attribute-selector.php` + - 287, 291, 295, 299, 303 — the `~= |= ^= $= *=` matcher operators. + - 330, 331, 336, 337 — the `i`/`I`/`s`/`S` case modifiers. +- `class-wp-css-compound-selector.php` + - 120, 122, 124 — the `.` / `#` / `[` subclass-selector dispatch. + +## Defensive guards unreachable from the public API (13 lines) + +These are internal precondition checks that the calling code already +guarantees, or branches for grammar the parser never emits: + +- `class-wp-css-attribute-selector.php:257` — `return null` when the first + byte is not `[`. `parse()` is only ever called by + `parse_subclass_selector()` *after* it has matched `[`, so the guard never + fires. +- `class-wp-css-complex-selector.php:170–179` — the `_doing_it_wrong` + "unsupported combinator" arm in the match walker. The parser only ever + stores `' '` (descendant) or `'>'` (child) combinators, so the match-time + default arm is dead defensively. +- `class-wp-css-compound-selector-list.php:87` — `return false` when the + processor is not on a `#tag` token. `select()` only invokes matching while + positioned on a tag; reachable only by calling `matches()` directly off a + non-tag token. +- `class-wp-css-selector-parser-matcher.php:130` — `parse_string()` EOF + guard; every caller checks bounds and the opening quote before calling. +- `class-wp-css-selector-parser-matcher.php:429` — + `check_if_three_code_points_would_start_an_ident_sequence()` EOF guard; + callers bound-check first. +- `class-wp-css-type-selector.php:45` — `return false` when `get_tag()` is + null during matching; matching only runs on resolved element tokens. +- `class-wp-css-type-selector.php:75` — `parse()` EOF guard; the compound + parser checks `offset < strlen` before calling. + +## Notes on what raised coverage + +- The `edge-escape` bucket drives the U+FFFD escape-decoder branch + (`consume_escaped_codepoint` for NUL / surrogate / over-max codepoints) + and the `normalize_selector_input` NUL→U+FFFD and CR/CRLF/FF→LF paths, + which the structural generators cannot reach. +- A few `invalid`-bucket templates (`[ a`, `[a="x\`) were added to reach + attribute/string parse guards that random structural generation rarely + lands on. diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 3f73c562a6857..a6bad339034e7 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -41,6 +41,10 @@ produces the same document, the same selector, and the same verdict. - `chaos` — arbitrary bytes; no parse expectation. - `mutated` — a supported selector with random byte mutations; no parse expectation. + - `edge-escape` — selectors that exercise otherwise-unreachable parser + branches: hex escapes for NUL / surrogate / over-max codepoints (must + decode to U+FFFD) and raw NUL / CR / CRLF / FF bytes in the input (must + normalize per `normalize_selector_input`); carries the intended AST. 4. Check invariants: - No PHP error/warning/exception from parsing or matching, ever. - Parse result (instance vs `null`) matches the bucket's expectation. @@ -123,6 +127,11 @@ Run a batch in-process (no isolation, faster): php tools/css-selector-fuzz/worker.php --start-seed 1 --count 500 +Measure line coverage of the `css/` classes (see `COVERAGE.md` for the +current report and a justified list of unreached lines): + + phpdbg -qrr tools/css-selector-fuzz/coverage.php --seeds 3000 --list-uncovered + Options of note: - `runner.php --stop-on-failure` stops at the first failing chunk. diff --git a/tools/css-selector-fuzz/coverage.php b/tools/css-selector-fuzz/coverage.php new file mode 100644 index 0000000000000..64c60a7d5e42c --- /dev/null +++ b/tools/css-selector-fuzz/coverage.php @@ -0,0 +1,92 @@ +#!/usr/bin/env php + $lines ) { + foreach ( $lines as $line => $hits ) { + $oplog[ $file ][ $line ] = true; + } + } +} + +$executable = phpdbg_get_executable( array( 'files' => $targets ) ); + +$total_exec = 0; +$total_covered = 0; + +foreach ( $targets as $file ) { + $exec_lines = array_keys( $executable[ $file ] ?? array() ); + $covered_lines = array_keys( $oplog[ $file ] ?? array() ); + $covered_lines = array_intersect( $covered_lines, $exec_lines ); + $uncovered = array_diff( $exec_lines, $covered_lines ); + + $total_exec += count( $exec_lines ); + $total_covered += count( $covered_lines ); + + printf( + "%-55s %4d/%4d lines %5.1f%%\n", + basename( $file ), + count( $covered_lines ), + count( $exec_lines ), + count( $exec_lines ) > 0 ? 100 * count( $covered_lines ) / count( $exec_lines ) : 100 + ); + + if ( $list_uncovered && array() !== $uncovered ) { + $source = file( $file ); + sort( $uncovered ); + foreach ( $uncovered as $line ) { + printf( " !%4d %s\n", $line, rtrim( $source[ $line - 1 ] ?? '' ) ); + } + } +} + +printf( + "%-55s %4d/%4d lines %5.1f%%\n", + 'TOTAL', + $total_covered, + $total_exec, + $total_exec > 0 ? 100 * $total_covered / $total_exec : 100 +); diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 131c3e970cefb..7bf1851f192e6 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -31,6 +31,7 @@ class SelectorGenerator { 'invalid', 'chaos', 'mutated', + 'edge-escape', ); /** @var Prng */ @@ -177,21 +178,23 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, $bucket = $prng->weighted( null === $rows || array() === $rows ? array( - 'supported-compound' => 30, - 'supported-complex' => 25, - 'unsupported' => 15, - 'invalid' => 12, + 'supported-compound' => 28, + 'supported-complex' => 24, + 'unsupported' => 14, + 'invalid' => 11, 'chaos' => 8, 'mutated' => 10, + 'edge-escape' => 5, ) : array( - 'supported-compound' => 24, - 'supported-complex' => 20, - 'path-directed' => 22, - 'unsupported' => 12, - 'invalid' => 10, + 'supported-compound' => 23, + 'supported-complex' => 19, + 'path-directed' => 21, + 'unsupported' => 11, + 'invalid' => 9, 'chaos' => 6, 'mutated' => 6, + 'edge-escape' => 5, ) ); } @@ -224,6 +227,9 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, case 'path-directed': return $generator->gen_path_directed( $rows ); + case 'edge-escape': + return $generator->gen_edge_escape(); + case 'unsupported': return array( 'bucket' => $bucket, @@ -521,6 +527,120 @@ private function pick_name( string $pool_key ): string { ); } + /* + * --------------------------- + * Edge-case escapes and input + * --------------------------- + * + * Targets parser branches the structural generators can't reach: + * - hex escapes whose codepoint is NUL / a surrogate / over-max, which + * `consume_escaped_codepoint` must decode to U+FFFD; + * - raw NUL / CR / CRLF / FF bytes in the selector input, which + * `normalize_selector_input` rewrites ( NUL→U+FFFD, the rest→LF ). + * + * These carry a known intended AST: the decoded ident is the U+FFFD + * replacement character ( or, for input normalization, the same selector + * with whitespace normalized ), so the AST round-trip still applies. + */ + private function gen_edge_escape(): array { + $kind = $this->prng->weighted( + array( + 'fffd-ident' => 50, + 'nul-input' => 25, + 'ws-input' => 25, + ) + ); + + if ( 'fffd-ident' === $kind ) { + // A class selector whose name is a single U+FFFD, produced by a + // hex escape for an out-of-range codepoint. + $hex = $this->prng->choice( + array( + '0', + '00', + '000000', + dechex( $this->prng->int( 0xD800, 0xDFFF ) ), // surrogate + dechex( $this->prng->int( 0x110000, 0xFFFFFF ) ), // over-max + ) + ); + if ( $this->prng->chance( 40 ) ) { + $hex = strtoupper( $hex ); + } + $selector = '.\\' . $hex . ' '; + $ast = array( + array( + 'context' => array(), + 'self' => array( + 'type' => null, + 'subs' => array( array( 'kind' => 'class', 'name' => "\u{FFFD}" ) ), + ), + ), + ); + return array( + 'bucket' => 'edge-escape', + 'selector' => $selector, + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => $ast, + ); + } + + /* + * Raw control bytes in the selector input. A small fixed compound + * keeps the case focused on normalize_selector_input and avoids + * entangling with unrelated attribute-selector edge cases. + */ + $compound = array( + 'type' => $this->prng->chance( 50 ) ? 'span' : null, + 'subs' => array( + array( 'kind' => 'class', 'name' => 'foo' ), + array( 'kind' => 'id', 'name' => 'bar' ), + ), + ); + if ( null === $compound['type'] && $this->prng->chance( 50 ) ) { + array_pop( $compound['subs'] ); + } + $rendered = $this->render_compound( $compound ); + + if ( 'nul-input' === $kind ) { + // A NUL between a class dot's selectors becomes part of an ident + // only in limited spots; simplest reliable case: a class whose + // name contains a NUL ( → U+FFFD ). + $ast = array( + array( + 'context' => array(), + 'self' => array( + 'type' => null, + 'subs' => array( array( 'kind' => 'class', 'name' => "a\u{FFFD}b" ) ), + ), + ), + ); + return array( + 'bucket' => 'edge-escape', + 'selector' => ".a\0b", + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => $ast, + ); + } + + // ws-input: wrap/insert CR, CRLF, FF as insignificant whitespace. + $lead = $this->prng->choice( array( "\r", "\f", "\r\n", "\r\r", "\f\f" ) ); + $trail = $this->prng->choice( array( "\r", "\f", "\r\n", '' ) ); + return array( + 'bucket' => 'edge-escape', + 'selector' => $lead . $rendered . $trail, + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => array( + array( + 'context' => array(), + 'self' => $compound, + ), + ), + ); + } + /* * ------------------------ * Path-directed generation @@ -1164,8 +1284,10 @@ private function gen_invalid(): string { '..a', '.#a', '[a', + '[ a', '[a=', '[a=]', + '[a="x\\', '[=b]', '[a==b]', '[a~b]', From 69f3fefad3e3d04b41dfebd991ca6f03bd1058c9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 13:53:58 +0200 Subject: [PATCH 162/336] CSS selector fuzz: automatic delta-debugging minimizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add minimize.php and Worker::run_pair. run_pair runs only the self-contained invariants — those computable from a (selector, html) pair without the generator's intended AST or parse expectation: WP select() vs the reference matcher over WP's own parsed AST and the captured tree, the metamorphic relations, the lexbor differential, and parse/shape/cross-grammar/rejection checks. All three known bugs reduce to a self-contained signature (Bug 1 -> metamorphic-ast, Bug 2 -> match-mismatch-html, Bug 3 -> metamorphic-parse). minimize.php ddmin-shrinks the HTML then the selector (HTML first so a selector-only signature collapses the document cheaply), preserving a chosen target signature. The metamorphic stage runs only when the target is itself metamorphic, stops at the first reproducing draw, and uses a fixed draw seed so shrinking stays monotonic. Verified on unpatched core: Bug 2 -> [type*=""] on
(8 bytes); Bug 3 -> a[aa="a"] (empty html); Bug 1 -> a 19-byte selector, empty html. --- tools/css-selector-fuzz/README.md | 11 ++ tools/css-selector-fuzz/lib/Worker.php | 164 +++++++++++++++++++++ tools/css-selector-fuzz/minimize.php | 189 +++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 tools/css-selector-fuzz/minimize.php diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index a6bad339034e7..ffa12efda964d 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -123,6 +123,17 @@ Probe a specific selector: php tools/css-selector-fuzz/replay.php --selector 'section > div.cls' --html '
' +Minimize a failing case to a small reproducer (delta-debugging; shrinks +both the selector and the HTML while preserving a failure signature): + + php tools/css-selector-fuzz/minimize.php --seed 1234 + php tools/css-selector-fuzz/minimize.php --selector 'sel' --html '<…>' --signature match-mismatch + +The minimizer drives `Worker::run_pair`, which checks only self-contained +invariants — those computable from the (selector, html) pair without the +generator's intended AST. All three known bugs reduce to one: Bug 1 → +`metamorphic-ast`, Bug 2 → `match-mismatch-html`, Bug 3 → `metamorphic-parse`. + Run a batch in-process (no isolation, faster): php tools/css-selector-fuzz/worker.php --start-seed 1 --count 500 diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 49b79fd39241a..e81f1ba677de9 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -43,6 +43,9 @@ class Worker { const SELECT_ITERATION_LIMIT = 10000; + /** Metamorphic PRNG draws tried per pair in run_pair (minimizer). */ + const PAIR_METAMORPH_DRAWS = 12; + /** * Runs a single fuzz case. * @@ -331,6 +334,167 @@ static function ( $failure ) { ); } + /** + * Runs the SELF-CONTAINED invariants on an explicit ( selector, html ) + * pair — no generated model, intended AST, or parse expectation. This is + * what the minimizer drives: every checked property is computable from + * the pair alone ( WP select() vs the reference matcher over WP's own + * parsed AST and the captured tree; metamorphic relations; the lexbor + * differential; parse/shape/cross-grammar invariants; rejection + * bookkeeping for unparseable selectors ). + * + * Bug 1 surfaces here as metamorphic-ast, Bug 2 as match-mismatch-*, + * Bug 3 as metamorphic-parse — so all three known bugs are minimizable + * without the generator. + * + * @return array{ + * failures: array, + * signatures: string[], + * } + */ + public static function run_pair( string $selector_string, string $html, ?string $target = null ): array { + Bootstrap::load(); + + $failures = array(); + $record = static function ( string $invariant, array $detail ) use ( &$failures ) { + $failures[] = array( + 'invariant' => $invariant, + 'detail' => $detail, + ); + }; + + // When the minimizer fixes a target signature, the metamorphic loop + // ( the only expensive, multi-draw stage ) is only worth running if + // the target is itself a metamorphic signature. + $target_invariant = null === $target ? null : substr( strrchr( $target, ':' ), 1 ); + $target_is_metamorph = null !== $target_invariant && 0 === strpos( $target_invariant, 'metamorphic' ); + $has_target_signature = static function () use ( &$failures, $target ) { + if ( null === $target ) { + return false; + } + foreach ( $failures as $failure ) { + if ( self::signature( $failure ) === $target ) { + return true; + } + } + return false; + }; + + list( $capture, $capture_error ) = self::guard( + static function () use ( $html ) { + return TreeCapture::capture( $html ); + } + ); + + $rows = null; + $tag_rows = null; + $quirks = false; + if ( null === $capture_error && null === $capture['error'] ) { + $rows = $capture['htmlRows']; + $tag_rows = $capture['tagRows']; + $quirks = $capture['quirks']; + } + + $document = array( 'html' => $html ); + + list( $compound_list, $compound_error ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + } + ); + list( $complex_list, $complex_error ) = self::guard( + static function () use ( $selector_string ) { + return \WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + } + ); + + if ( null !== $compound_error ) { + $record( 'parse-error', array( 'grammar' => 'compound', 'error' => self::describe_throwable( $compound_error ) ) ); + } + if ( null !== $complex_error ) { + $record( 'parse-error', array( 'grammar' => 'complex', 'error' => self::describe_throwable( $complex_error ) ) ); + } + if ( null !== $compound_list && null === $complex_list && null === $complex_error ) { + $record( 'compound-implies-complex', array() ); + } + + $compound_ast = null; + $complex_ast = null; + if ( null !== $compound_list ) { + list( $compound_ast, $shape_error ) = self::guard( + static function () use ( $compound_list ) { + return AstExtractor::from_compound_list( $compound_list ); + } + ); + if ( null !== $shape_error ) { + $record( 'ast-shape', array( 'grammar' => 'compound', 'error' => self::describe_throwable( $shape_error ) ) ); + } + } + if ( null !== $complex_list ) { + list( $complex_ast, $shape_error ) = self::guard( + static function () use ( $complex_list ) { + return AstExtractor::from_complex_list( $complex_list ); + } + ); + if ( null !== $shape_error ) { + $record( 'ast-shape', array( 'grammar' => 'complex', 'error' => self::describe_throwable( $shape_error ) ) ); + } + } + if ( null !== $compound_ast && null !== $complex_ast && $compound_ast !== $complex_ast ) { + $record( 'ast-cross-grammar', array( 'compoundAst' => $compound_ast, 'complexAst' => $complex_ast ) ); + } + + $html_matches = null; + if ( null !== $complex_ast && null !== $rows ) { + $expected = ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, $quirks ); + $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); + self::check_lexbor_differential( $complex_ast, $selector_string, $document, $rows, $quirks, $expected, $record ); + } elseif ( null === $complex_list && null === $complex_error && null !== $rows ) { + self::check_select_rejection( 'html', $selector_string, $document, $record ); + } + + if ( null !== $compound_ast && null !== $tag_rows ) { + $expected = ReferenceMatcher::expected_tag_matches_rows( $compound_ast, $tag_rows ); + self::check_select_matches( 'tag', $selector_string, $document, $expected, $record ); + } elseif ( null === $compound_list && null === $compound_error && null !== $tag_rows ) { + self::check_select_rejection( 'tag', $selector_string, $document, $record ); + } + + $run_metamorph = ( null === $target || $target_is_metamorph ) + && null !== $complex_ast && null !== $html_matches && array() === $failures; + if ( $run_metamorph ) { + /* + * Metamorphic transforms randomize escapes / case / order, so a + * transform-sensitive bug ( e.g. Bug 1 and Bug 3 ) only fires for + * some PRNG draws. run_case sees one draw; here several fixed + * draws are tried so minimization can reliably preserve such a + * signature regardless of which draw first exposed it. With a + * target fixed, stop at the first draw that reproduces it. + */ + for ( $i = 0; $i < self::PAIR_METAMORPH_DRAWS && array() === $failures; $i++ ) { + // A FIXED draw seed ( not derived from the pair ) keeps the + // test monotonic under shrinking: the same coin-flips apply to + // whatever AST survives, so a smaller selector that still has + // the bug reproduces the same transform signature. + $metamorph_prng = new Prng( 'css-selector-fuzz-minimize', "metamorph:{$i}" ); + self::check_metamorphic( $complex_ast, $html_matches, $document, $metamorph_prng, $record ); + if ( $has_target_signature() ) { + break; + } + } + } + + $signatures = array(); + foreach ( $failures as $failure ) { + $signatures[] = self::signature( $failure ); + } + + return array( + 'failures' => $failures, + 'signatures' => array_values( array_unique( $signatures ) ), + ); + } + /** * Verifies that the processor's captured view of a safe (model-built) * document agrees with the generated model — this guards the oracle diff --git a/tools/css-selector-fuzz/minimize.php b/tools/css-selector-fuzz/minimize.php new file mode 100644 index 0000000000000..3f3870b97b247 --- /dev/null +++ b/tools/css-selector-fuzz/minimize.php @@ -0,0 +1,189 @@ +#!/usr/bin/env php + metamorphic-ast, Bug 2 -> match-mismatch-html, Bug 3 -> + * metamorphic-parse. + * + * Usage: + * php tools/css-selector-fuzz/minimize.php --seed 1234 [--signature SUBSTR] + * php tools/css-selector-fuzz/minimize.php --selector 'sel' --html '<…>' [--signature SUBSTR] + * + * Options: + * --signature SUBSTR Target a signature whose id or invariant contains + * SUBSTR (default: the first signature of the seed's + * failure set). + * --max-attempts N Cap test evaluations (default 4000). + * --json Emit the reproducer as JSON. + */ + +require_once __DIR__ . '/lib/autoload.php'; + +use CssSelectorFuzz\Worker; +use function CssSelectorFuzz\json_encode_safe; +use function CssSelectorFuzz\option_bool; +use function CssSelectorFuzz\option_int; +use function CssSelectorFuzz\option_string; +use function CssSelectorFuzz\parse_cli_options; +use function CssSelectorFuzz\printable_bytes; + +$options = parse_cli_options( $argv ); +$max_attempts = option_int( $options, 'max-attempts', 20000 ); +$sig_filter = option_string( $options, 'signature', null ); + +$seed = option_int( $options, 'seed', -1 ); +if ( $seed >= 0 ) { + $case = Worker::run_case( $seed ); + $selector = $case['selector']; + $html = $case['html']; +} else { + $selector = option_string( $options, 'selector', null ); + $html = option_string( $options, 'html', null ); + if ( null === $selector || null === $html ) { + fwrite( STDERR, "Provide --seed N, or both --selector and --html.\n" ); + exit( 1 ); + } +} + +/** Signatures produced by a pair ( $target lets run_pair short-circuit ). */ +$signatures_of = static function ( string $selector, string $html, ?string $target = null ): array { + return Worker::run_pair( $selector, $html, $target )['signatures']; +}; + +$baseline = $signatures_of( $selector, $html ); +if ( array() === $baseline ) { + fwrite( STDERR, "The starting pair does not reproduce any self-contained failure.\n" ); + fwrite( STDERR, 'selector: ' . printable_bytes( $selector ) . "\n" ); + exit( 1 ); +} + +// Pick the target signature. +$target = $baseline[0]; +if ( null !== $sig_filter ) { + foreach ( $baseline as $candidate ) { + if ( false !== strpos( $candidate, $sig_filter ) ) { + $target = $candidate; + break; + } + } +} + +$attempts = 0; +$reproduces = static function ( string $selector, string $html ) use ( $signatures_of, $target, &$attempts, $max_attempts ): bool { + if ( $attempts >= $max_attempts ) { + return false; + } + ++$attempts; + return in_array( $target, $signatures_of( $selector, $html, $target ), true ); +}; + +/** + * Delta-debugging shrink of one byte string: ddmin chunk removal followed + * by per-position single-byte simplification. $test( candidate ) decides + * whether a candidate still reproduces. + */ +$shrink = static function ( string $current, callable $test ) use ( &$attempts, $max_attempts ): string { + $chunks = 2; + while ( strlen( $current ) > 0 && $attempts < $max_attempts ) { + $length = strlen( $current ); + $chunk_size = (int) ceil( $length / $chunks ); + $changed = false; + + for ( $offset = 0; $offset < $length && $attempts < $max_attempts; $offset += $chunk_size ) { + $candidate = substr( $current, 0, $offset ) . substr( $current, min( $length, $offset + $chunk_size ) ); + if ( $candidate === $current ) { + continue; + } + if ( $test( $candidate ) ) { + $current = $candidate; + $chunks = max( 2, $chunks - 1 ); + $changed = true; + break; + } + } + + if ( ! $changed ) { + if ( $chunks >= $length ) { + break; + } + $chunks = min( $length, $chunks * 2 ); + } + } + + // Per-byte canonicalization: replace each byte with a simpler stand-in. + $replacements = array( 'a', ' ', '' ); + for ( $i = 0; $i < strlen( $current ) && $attempts < $max_attempts; $i++ ) { + foreach ( $replacements as $replacement ) { + $candidate = substr( $current, 0, $i ) . $replacement . substr( $current, $i + 1 ); + if ( $candidate === $current ) { + continue; + } + if ( $test( $candidate ) ) { + $current = $candidate; + $i = max( -1, $i - 2 ); + break; + } + } + } + + return $current; +}; + +// Alternate shrinking the HTML and the selector until neither moves. +// HTML first: when the signature is selector-only (e.g. metamorphic-parse) +// the document collapses cheaply before the costlier selector pass. +$prev = null; +while ( $attempts < $max_attempts && ( $selector . "\0" . $html ) !== $prev ) { + $prev = $selector . "\0" . $html; + + $html = $shrink( + $html, + static function ( string $candidate ) use ( $reproduces, &$selector ): bool { + return $reproduces( $selector, $candidate ); + } + ); + $selector = $shrink( + $selector, + static function ( string $candidate ) use ( $reproduces, &$html ): bool { + return $reproduces( $candidate, $html ); + } + ); +} + +$final = $signatures_of( $selector, $html ); +$ok = in_array( $target, $final, true ); + +if ( option_bool( $options, 'json', false ) ) { + echo json_encode_safe( + array( + 'target' => $target, + 'reproduced' => $ok, + 'attempts' => $attempts, + 'selector' => printable_bytes( $selector ), + 'selectorBytes' => strlen( $selector ), + 'html' => printable_bytes( $html ), + 'htmlBytes' => strlen( $html ), + 'selectorBase64' => base64_encode( $selector ), + 'htmlBase64' => base64_encode( $html ), + ) + ) . "\n"; + exit( $ok ? 0 : 2 ); +} + +echo "target: {$target}\n"; +echo 'reproduced: ' . ( $ok ? 'yes' : 'NO' ) . "\n"; +echo "attempts: {$attempts}\n"; +echo 'selector: ' . printable_bytes( $selector ) . ' (' . strlen( $selector ) . " bytes)\n"; +echo 'html: ' . printable_bytes( $html ) . ' (' . strlen( $html ) . " bytes)\n"; +echo "\nreplay:\n"; +echo ' php tools/css-selector-fuzz/replay.php --selector ' . escapeshellarg( $selector ) + . ' --html ' . escapeshellarg( $html ) . "\n"; +exit( $ok ? 0 : 2 ); From 5da3afedd0db58dfcb028dbe5cf13b5cae2da1c3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:00:25 +0200 Subject: [PATCH 163/336] CSS selector fuzz: fragment parsing + quirks-trigger coverage Add a -context fragment mode (~20% of safe-document cases): DocumentGenerator::generate_fragment renders body-level content without the document wrapper, TreeCapture parses it via create_fragment and captures the tree (with the implicit HTML/BODY ancestors the fragment parser reports in breadcrumbs), and the match / metamorphic invariants run against it. collect_matches and the rejection check route through create_fragment when the case is a fragment; the tag processor (no fragment mode) and lexbor (full-document only) are skipped for fragments. is the only context create_fragment accepts publicly. Quirks-mode triggers were already broadened by the wild generator's five doctype variants (none / html / legacy-compat SYSTEM / quirky PUBLIC / limited-quirks): ~111 quirks vs ~213 no-quirks per 400 wild docs, and is_quirks_mode() is captured per case and honored by the reference matcher. 294 fragment cases over 2000 seeds run clean with capture == model; full 2000-seed batch clean against core with the three known fixes. --- tools/css-selector-fuzz/README.md | 8 +- .../lib/DocumentGenerator.php | 97 ++++++++++++++++ tools/css-selector-fuzz/lib/TreeCapture.php | 23 +++- tools/css-selector-fuzz/lib/Worker.php | 105 ++++++++++++++---- 4 files changed, 208 insertions(+), 25 deletions(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index ffa12efda964d..3042338f18917 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -10,8 +10,12 @@ produces the same document, the same selector, and the same verdict. ## What a case does 1. Generate a random HTML document — 70% from a structurally "safe" element - set with a known model tree, 30% "wild" (misnested, implied-end-tag, - foreign-content, varied-doctype token soup with no model). + set with a known model tree (of these, ~20% are parsed as a `` + fragment via `create_fragment` instead of a full document, exercising the + fragment `select()` path), 30% "wild" (misnested, implied-end-tag, + foreign-content, token soup with one of five doctypes spanning no-quirks, + quirks, and limited-quirks). `create_fragment` only accepts the `` + context publicly, so that is the fragment context fuzzed. 2. Capture the processor's own view of the document as the matching oracle's ground truth (`TreeCapture`): a flat list of rows in visit order, each carrying the element's tag, attributes, and ancestor tag list (context diff --git a/tools/css-selector-fuzz/lib/DocumentGenerator.php b/tools/css-selector-fuzz/lib/DocumentGenerator.php index e435edce409c9..20e6e99607421 100644 --- a/tools/css-selector-fuzz/lib/DocumentGenerator.php +++ b/tools/css-selector-fuzz/lib/DocumentGenerator.php @@ -106,6 +106,103 @@ public static function generate( Prng $prng ): array { return $generator->build(); } + /** + * Generates a ``-context fragment: body-level content rendered + * without the document wrapper, parsed via create_fragment. The model's + * top-level elements carry the implicit BODY/HTML ancestors the fragment + * parser reports in breadcrumbs. + * + * @return array{ + * model: null, + * children: array, + * html: string, + * context: string, + * fragment: true, + * quirks: bool, + * pools: array, + * } + */ + public static function generate_fragment( Prng $prng ): array { + $generator = new self( $prng, $prng->int( 6, 30 ) ); + return $generator->build_fragment(); + } + + private function build_fragment(): array { + $children = array(); + $child_budget = $this->prng->int( 1, 6 ); + for ( $i = 0; $i < $child_budget && $this->element_count < $this->max_elements; $i++ ) { + $children[] = $this->random_subtree( 0 ); + } + + $bits = array(); + foreach ( $children as $child ) { + $bits[] = $this->render_element( $child ); + } + $filler = array( '', 'text', ' more ', "\n ", '& x', 'café ✓', '' ); + $html = ''; + foreach ( $bits as $bit ) { + if ( $this->prng->chance( 35 ) ) { + $html .= $this->prng->choice( $filler ); + } + $html .= $bit; + } + + foreach ( $this->pools as $key => $values ) { + $this->pools[ $key ] = array_values( array_unique( $values ) ); + } + + return array( + 'model' => null, + 'children' => $children, + 'html' => $html, + 'context' => '', + 'fragment' => true, + 'quirks' => false, + 'pools' => $this->pools, + ); + } + + /** + * Rows ( TreeCapture shape ) for a ``-context fragment: the + * top-level children flattened with the implicit HTML/BODY ancestors the + * fragment parser reports. + */ + public static function rows_from_fragment( array $children ): array { + $html_root = array( 'tag' => 'html', 'fid' => '(html)', 'attrs' => array(), 'children' => array() ); + $body_root = array( 'tag' => 'body', 'fid' => '(body)', 'attrs' => array(), 'children' => $children ); + + $rows = array(); + foreach ( $children as $child ) { + foreach ( self::flatten_with_ancestors( $child, array( $body_root, $html_root ) ) as $pair ) { + list( $element, $ancestors ) = $pair; + + $attrs = array(); + $seen = array(); + foreach ( $element['attrs'] as $attr ) { + $lower = ascii_strtolower( $attr[0] ); + if ( isset( $seen[ $lower ] ) ) { + continue; + } + $seen[ $lower ] = true; + $attrs[] = array( $lower, $attr[1] ); + } + + $ancestor_tags = array(); + foreach ( $ancestors as $ancestor ) { + $ancestor_tags[] = strtoupper( ascii_strtolower( $ancestor['tag'] ) ); + } + + $rows[] = array( + 'tag' => strtoupper( ascii_strtolower( $element['tag'] ) ), + 'fid' => $element['fid'], + 'attrs' => $attrs, + 'ancestorTags' => $ancestor_tags, + ); + } + } + return $rows; + } + private function build(): array { $has_doctype = $this->prng->chance( 85 ); diff --git a/tools/css-selector-fuzz/lib/TreeCapture.php b/tools/css-selector-fuzz/lib/TreeCapture.php index 9edc3b0541757..bd64dc30eb6de 100644 --- a/tools/css-selector-fuzz/lib/TreeCapture.php +++ b/tools/css-selector-fuzz/lib/TreeCapture.php @@ -24,6 +24,13 @@ class TreeCapture { const CAPTURE_ITERATION_LIMIT = 20000; /** + * Captures the processor's view of a document or a fragment. + * + * @param string $html The markup ( full document or fragment ). + * @param string|null $context When set, parse as a fragment in this + * context ( e.g. '' ); the tag + * processor has no fragment mode, so tagRows + * is null in that case. * @return array{ * htmlRows: array|null, * tagRows: array|null, @@ -31,7 +38,7 @@ class TreeCapture { * error: string|null, * } */ - public static function capture( string $html ): array { + public static function capture( string $html, ?string $context = null ): array { $out = array( 'htmlRows' => null, 'tagRows' => null, @@ -39,7 +46,13 @@ public static function capture( string $html ): array { 'error' => null, ); - $processor = \WP_HTML_Processor::create_full_parser( $html ); + $processor = null === $context + ? \WP_HTML_Processor::create_full_parser( $html ) + : \WP_HTML_Processor::create_fragment( $html, $context ); + if ( null === $processor ) { + $out['error'] = 'fragment-context-unsupported'; + return $out; + } $rows = array(); $iterations = 0; while ( $processor->next_tag() ) { @@ -69,6 +82,12 @@ public static function capture( string $html ): array { $out['htmlRows'] = $rows; $out['quirks'] = $processor->is_quirks_mode(); + // The tag processor has no fragment mode; a fragment case exercises + // the html processor's select() only. + if ( null !== $context ) { + return $out; + } + $tag_processor = new \WP_HTML_Tag_Processor( $html ); $tag_rows = array(); $iterations = 0; diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index e81f1ba677de9..46ad6f7fb3bd8 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -61,8 +61,9 @@ class Worker { public static function run_case( int $seed ): array { Bootstrap::load(); - $prng = new Prng( (string) $seed, 'css-selector-fuzz-case' ); - $is_wild = $prng->chance( 30 ); + $prng = new Prng( (string) $seed, 'css-selector-fuzz-case' ); + $is_wild = $prng->chance( 30 ); + $is_fragment = ! $is_wild && $prng->chance( 20 ); $failures = array(); $record = static function ( string $invariant, array $detail ) use ( &$failures ) { @@ -88,13 +89,18 @@ public static function run_case( int $seed ): array { $capture_error = null; $attempts = $is_wild ? 8 : 1; for ( $attempt = 0; $attempt < $attempts; $attempt++ ) { - $document = $is_wild - ? WildDocumentGenerator::generate( $prng->fork( "wild-document:{$attempt}" ) ) - : DocumentGenerator::generate( $prng->fork( 'document' ) ); + if ( $is_wild ) { + $document = WildDocumentGenerator::generate( $prng->fork( "wild-document:{$attempt}" ) ); + } elseif ( $is_fragment ) { + $document = DocumentGenerator::generate_fragment( $prng->fork( 'fragment' ) ); + } else { + $document = DocumentGenerator::generate( $prng->fork( 'document' ) ); + } + $context = ( $document['fragment'] ?? false ) ? $document['context'] : null; list( $capture, $capture_error ) = self::guard( - static function () use ( $document ) { - return TreeCapture::capture( $document['html'] ); + static function () use ( $document, $context ) { + return TreeCapture::capture( $document['html'], $context ); } ); @@ -120,7 +126,9 @@ static function () use ( $document ) { $tag_rows = $capture['tagRows']; $quirks = $capture['quirks']; - if ( ! $is_wild ) { + if ( $is_fragment ) { + self::check_fragment_capture_against_model( $document, $capture, $record ); + } elseif ( ! $is_wild ) { self::check_capture_against_model( $document, $capture, $record ); } } @@ -283,7 +291,10 @@ static function () use ( $complex_list ) { $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); - $lexbor_state = self::check_lexbor_differential( $complex_ast, $selector_string, $document, $rows, $quirks, $expected, $record ); + // lexbor parses full documents only; fragments skip it. + if ( ! ( $document['fragment'] ?? false ) ) { + $lexbor_state = self::check_lexbor_differential( $complex_ast, $selector_string, $document, $rows, $quirks, $expected, $record ); + } } elseif ( null === $complex_list && null === $complex_error ) { self::check_select_rejection( 'html', $selector_string, $document, $record ); } @@ -495,6 +506,46 @@ static function () use ( $complex_list ) { ); } + /** + * Fragment analogue of check_capture_against_model: the ``-context + * fragment capture must equal the model rows built from the body-level + * children ( with the implicit HTML/BODY ancestors ). + */ + private static function check_fragment_capture_against_model( array $document, array $capture, callable $record ): void { + $model_rows = DocumentGenerator::rows_from_fragment( $document['children'] ); + + $normalize = static function ( array $rows ): array { + $out = array(); + foreach ( $rows as $row ) { + $attrs = array(); + foreach ( $row['attrs'] as $attr ) { + $attrs[ $attr[0] ] = $attr[1]; + } + ksort( $attrs ); + $out[] = array( + 'tag' => $row['tag'], + 'fid' => $row['fid'], + 'attrs' => $attrs, + 'ancestorTags' => $row['ancestorTags'], + ); + } + return $out; + }; + + $expected = $normalize( $model_rows ); + $actual = $normalize( $capture['htmlRows'] ); + if ( $expected !== $actual ) { + $record( + 'model-desync', + array( + 'processor' => 'fragment', + 'expected' => $expected, + 'actual' => $actual, + ) + ); + } + } + /** * Verifies that the processor's captured view of a safe (model-built) * document agrees with the generated model — this guards the oracle @@ -566,15 +617,22 @@ private static function check_capture_against_model( array $document, array $cap /** * Runs a select() loop over the document, collecting matched data-fids. * - * @param string $target 'html' or 'tag'. + * @param string $target 'html' or 'tag'. + * @param array $document The case document ( may request fragment mode ). * @return array{0: string[]|null, 1: \Throwable|null} */ - private static function collect_matches( string $target, string $selector_string, string $html ): array { + private static function collect_matches( string $target, string $selector_string, array $document ): array { + $html = $document['html']; + $context = ( $document['fragment'] ?? false ) ? $document['context'] : null; return self::guard( - static function () use ( $target, $selector_string, $html ) { - $processor = 'html' === $target - ? \WP_HTML_Processor::create_full_parser( $html ) - : new \WP_HTML_Tag_Processor( $html ); + static function () use ( $target, $selector_string, $html, $context ) { + if ( 'tag' === $target ) { + $processor = new \WP_HTML_Tag_Processor( $html ); + } elseif ( null !== $context ) { + $processor = \WP_HTML_Processor::create_fragment( $html, $context ); + } else { + $processor = \WP_HTML_Processor::create_full_parser( $html ); + } $matches = array(); $iterations = 0; @@ -610,7 +668,7 @@ static function () use ( $target, $selector_string, $html ) { private static function check_select_matches( string $target, string $selector_string, array $document, array $expected, callable $record ): ?array { Bootstrap::reset_doing_it_wrong(); - list( $actual, $error ) = self::collect_matches( $target, $selector_string, $document['html'] ); + list( $actual, $error ) = self::collect_matches( $target, $selector_string, $document ); if ( null !== $error ) { $record( @@ -831,7 +889,7 @@ static function () use ( $variant_list ) { } Bootstrap::reset_doing_it_wrong(); - list( $variant_matches, $match_error ) = self::collect_matches( 'html', $variant_selector, $document['html'] ); + list( $variant_matches, $match_error ) = self::collect_matches( 'html', $variant_selector, $document ); if ( null !== $match_error ) { $record( @@ -866,11 +924,16 @@ static function () use ( $variant_list ) { private static function check_select_rejection( string $target, string $selector_string, array $document, callable $record ): void { Bootstrap::reset_doing_it_wrong(); + $context = ( $document['fragment'] ?? false ) ? $document['context'] : null; list( $results, $error ) = self::guard( - static function () use ( $target, $selector_string, $document ) { - $processor = 'html' === $target - ? \WP_HTML_Processor::create_full_parser( $document['html'] ) - : new \WP_HTML_Tag_Processor( $document['html'] ); + static function () use ( $target, $selector_string, $document, $context ) { + if ( 'tag' === $target ) { + $processor = new \WP_HTML_Tag_Processor( $document['html'] ); + } elseif ( null !== $context ) { + $processor = \WP_HTML_Processor::create_fragment( $document['html'], $context ); + } else { + $processor = \WP_HTML_Processor::create_full_parser( $document['html'] ); + } // Two calls: the second exercises the parse cache. return array( $processor->select( $selector_string ), $processor->select( $selector_string ) ); From 44058b4c015da49ce70dcb8497f82819e79b5225 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:04:47 +0200 Subject: [PATCH 164/336] CSS selector fuzz: triage 5000-seed run, update findings and roadmap 5000-seed isolated run on core @ 46334f170b: 427 failures, 0 crashes, every failure triaged to one of the three known bugs (Bug 1 -> metamorphic-ast / ast-mismatch / path-expectation; Bug 2 -> match-mismatch-html/tag; Bug 3 -> metamorphic-parse / parse-expectation). Zero lexbor-divergence (the third oracle agreed with the reference matcher on every compared no-quirks case) and zero model-desync. With all three fixes applied the same 5000 seeds run completely clean, confirming the fuzzer reports exactly these three bugs and no spurious oracle/generator defect. FINDINGS.md: add the signature->bug triage table and refresh the fuzzer status. NEXT-STEPS.md: mark the roadmap complete against the acceptance bar. All three known bugs verified still reproducing. --- tools/css-selector-fuzz/FINDINGS.md | 54 ++++++++++++++++++++++++--- tools/css-selector-fuzz/NEXT-STEPS.md | 13 ++++--- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index 981b33ef41d80..ad9f47c3458b7 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -1,11 +1,17 @@ # CSS Selector Fuzzer — Findings -Run: branch `html-css-fuzz` @ `6ebbcc2fe4`, PHP 8.4.21. ~3600 deterministic +Run: branch `html-css-fuzz` @ `46334f170b`, PHP 8.4.21. 5000 deterministic seeds, 0 crashes/timeouts. Three distinct, reproduced WordPress-core correctness bugs in the new HTML-API CSS selector support. Every selector below is valid, supported CSS that the API mis-handles **without** reporting lack of support. +No new bugs surfaced beyond these three, and no fuzzer-side (oracle or +generator) defect surfaced: with all three fixes applied a 5000-seed run is +completely clean, and the lexbor differential (third independent oracle) agreed +with the reference matcher on every compared no-quirks case (0 `lexbor-divergence`). + Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '' [--html '']`. +Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed `. --- @@ -105,10 +111,46 @@ character of the selector string. --- +## Triage of the 5000-seed run (unpatched core) + +427 failures, every one attributable to one of the three bugs above. The +signature → bug mapping (and why each is a WP finding, not a fuzzer defect): + +| signature | hits | bug | how it manifests | +|---|---|---|---| +| `metamorphic-ast` (5 variants) | 328 | Bug 1 | a re-rendered / escaped variant of a selector parses to a different AST because an identity escape after multibyte content mis-decodes | +| `ast-mismatch` | 71 | Bug 1 | generated AST ≠ parsed AST, same root cause | +| `path-expectation` | 1 | Bug 1 | a path-directed selector with a multibyte-then-identity-escape value (`Über90\ x`) mis-parses, so the element it was built from no longer matches | +| `metamorphic-parse` (4 variants) | 9 | Bug 3 | a re-rendered variant ending in a single-char unquoted value at EOF is wrongly rejected | +| `parse-expectation` (2 variants) | 7 | Bug 3 | the generated selector itself ends in `=x]` and is wrongly rejected (e.g. `[dir =a]`) | +| `match-mismatch-html` | 7 | Bug 2 | empty-operand `^= *= $=` match elements the spec says they must not | +| `match-mismatch-tag` | 4 | Bug 2 | same, via the tag processor | + +Zero `lexbor-divergence`, zero `model-desync`, zero crashes/timeouts. With all +three fixes applied, the same 5000 seeds run with **0 failures** — confirming +the fuzzer reports exactly these three bugs and nothing spurious. + ## Fuzzer status -Implemented and validated: deterministic seeds, seed-based replay, generative -6-bucket selector generation, independent reference matcher, ~18 invariants, -process-isolated runner, self-check suite. `php tools/css-selector-fuzz/tests/self-check.php` -passes; see `README.md` for usage. No fuzzer-side (oracle/generator) defects -surfaced in 3600 seeds — all failures are the three target bugs above. +Implemented and validated: + +- Deterministic seeds, seed-based replay, self-check suite + (`php tools/css-selector-fuzz/tests/self-check.php` passes). +- Seven-bucket selector generation including **path-directed** synthesis + (combinator positive-match rate ~68% vs ~14% before) and **edge-escape** + (U+FFFD escape decoder, input normalization). +- Three independent match oracles: the spec-faithful `ReferenceMatcher`, the + AST round-trip, and a **lexbor differential** (liblexbor v3.0.0, no-quirks + documents, tree-equality gated). The three agree on every compared case. +- **Metamorphic invariants** (oracle-free): meaning-preserving transforms keep + the match set; AST-preserving transforms keep the AST. +- **Parser-derived oracle tree** (`TreeCapture`): the processor's own parse is + ground truth, so **wild / restructured HTML** and **`` fragments** are + fuzzed, not only clean trees. +- **Line coverage** measured (93.8%, see `COVERAGE.md`; 96.8% of reachable + code, remainder justified). +- **Automatic minimizer** (`minimize.php`): delta-debugs selector and HTML to a + minimal reproducer preserving a chosen signature. + +See `README.md` for usage and `NEXT-STEPS.md` for the roadmap this work +completed. diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 0b509450a177e..86fa37c61eb93 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -1,10 +1,13 @@ # CSS Selector Fuzzer — Next Steps / Improvement Roadmap -Status: first-generation fuzzer is implemented, validated, and has found three -real WordPress-core bugs (see `FINDINGS.md`). Design and current coverage are in -`README.md`. This document is the prioritized plan to take it from "found three -bugs" to "exhaustive and trustworthy." Do NOT re-explain the existing tool here; -read `README.md` and `FINDINGS.md` first. +> **Status: all seven work items below are implemented and validated** (see +> `README.md`, `COVERAGE.md`, `FINDINGS.md`). The acceptance bar is met: +> coverage measured (93.8%; 96.8% of reachable code, remainder justified); +> three oracles agree on no-quirks supported cases with every divergence +> triaged; metamorphic invariants passing; combinator positive-match rate +> raised from 14.5% to ~68% (path-directed bucket); minimizer working; a clean +> 5000-seed run with all signatures triaged to the three known bugs, all of +> which still reproduce. The notes below are retained as the design rationale. Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). From 7336efad279a0790488817a42a580f0bb20d794a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:37:15 +0200 Subject: [PATCH 165/336] CSS selector fuzz: stop the minimizer silently retargeting a different bug Reviewer found minimize.php --seed could print 'reproduced: yes' for a signature the seed never produced: run_pair only checks self-contained invariants, so a seed whose recorded failure is generator-side (ast-mismatch, parse-expectation, path-expectation, model-desync) would fall through to minimizing an unrelated incidental self-contained signature and report success. run_case now returns its per-case signatures. minimize.php --seed restricts targets to signatures sharing an INVARIANT with the seed's own failures (invariant-level, not exact-hash, so the same metamorphic invariant exposed by a different transform draw still counts as faithful). When the seed's failures are entirely generator-side it refuses by default and lists the nearby self-contained signatures; --signature opts into a retarget, which is then explicitly labelled 'retargeted' / NOTE in the output. Docs updated to scope --seed accordingly. --- tools/css-selector-fuzz/FINDINGS.md | 5 +- tools/css-selector-fuzz/README.md | 18 +++++- tools/css-selector-fuzz/lib/Worker.php | 20 ++++-- tools/css-selector-fuzz/minimize.php | 88 ++++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index ad9f47c3458b7..302d6218a6f0a 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -11,7 +11,10 @@ completely clean, and the lexbor differential (third independent oracle) agreed with the reference matcher on every compared no-quirks case (0 `lexbor-divergence`). Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '' [--html '']`. -Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed `. +Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed ` +(faithful for seeds with a self-contained failure; seeds whose only recorded +failure is generator-side — `ast-mismatch`, `parse-expectation` — are refused +unless a related self-contained signature is opted into with `--signature`). --- diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 3042338f18917..2c60bf5c73a51 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -133,10 +133,22 @@ both the selector and the HTML while preserving a failure signature): php tools/css-selector-fuzz/minimize.php --seed 1234 php tools/css-selector-fuzz/minimize.php --selector 'sel' --html '<…>' --signature match-mismatch -The minimizer drives `Worker::run_pair`, which checks only self-contained +The minimizer drives `Worker::run_pair`, which checks only **self-contained** invariants — those computable from the (selector, html) pair without the -generator's intended AST. All three known bugs reduce to one: Bug 1 → -`metamorphic-ast`, Bug 2 → `match-mismatch-html`, Bug 3 → `metamorphic-parse`. +generator's intended AST: `match-mismatch-*`, `metamorphic-*`, +`lexbor-divergence`, `parse-error`, `ast-shape`, `ast-cross-grammar`, and the +rejection checks. The generator-side invariants `ast-mismatch`, +`parse-expectation`, `path-expectation`, and `model-desync` are **not** +self-contained and cannot be reproduced from the pair alone. + +So `--seed` faithfully minimizes only seeds whose failure is self-contained. +The three known bugs each *also* surface a self-contained signature (Bug 1 → +`metamorphic-ast`, Bug 2 → `match-mismatch-html`, Bug 3 → `metamorphic-parse`), +but a seed whose recorded failure is *only* the generator-side form (e.g. a +Bug-1 seed that recorded `ast-mismatch` before the metamorphic phase ran) is +**refused by default** rather than silently retargeted — pass `--signature` +to opt into minimizing a related self-contained signature, which is then +clearly labelled as a retarget in the output. Run a batch in-process (no isolation, faster): diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 46ad6f7fb3bd8..6371b617cc028 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -334,14 +334,20 @@ static function ( $failure ) { ) ); + $signatures = array(); + foreach ( $failures as $failure ) { + $signatures[] = self::signature( $failure ); + } + return array( - 'seed' => $seed, - 'bucket' => $selector['bucket'], - 'digest' => $digest, - 'failures' => $failures, - 'selector' => $selector_string, - 'html' => $document['html'], - 'lexbor' => $lexbor_state, + 'seed' => $seed, + 'bucket' => $selector['bucket'], + 'digest' => $digest, + 'failures' => $failures, + 'signatures' => array_values( array_unique( $signatures ) ), + 'selector' => $selector_string, + 'html' => $document['html'], + 'lexbor' => $lexbor_state, ); } diff --git a/tools/css-selector-fuzz/minimize.php b/tools/css-selector-fuzz/minimize.php index 3f3870b97b247..edfc892689fa7 100644 --- a/tools/css-selector-fuzz/minimize.php +++ b/tools/css-selector-fuzz/minimize.php @@ -39,11 +39,27 @@ $max_attempts = option_int( $options, 'max-attempts', 20000 ); $sig_filter = option_string( $options, 'signature', null ); -$seed = option_int( $options, 'seed', -1 ); +/* + * In --seed mode, the seed's OWN failures ( from run_case ) are the source + * of truth. The minimizer can only preserve "self-contained" signatures + * ( those run_pair re-checks without the generator's intended AST ); the + * generator-side ones ( ast-mismatch, parse-expectation, path-expectation, + * model-desync ) are invisible to run_pair. Targeting must therefore be + * restricted to the intersection of the seed's failures and run_pair's + * view — otherwise the minimizer could silently retarget to an unrelated + * incidental signature and report a false "reproduced". + */ +$seed = option_int( $options, 'seed', -1 ); +$seed_signatures = null; if ( $seed >= 0 ) { - $case = Worker::run_case( $seed ); - $selector = $case['selector']; - $html = $case['html']; + $case = Worker::run_case( $seed ); + $selector = $case['selector']; + $html = $case['html']; + $seed_signatures = $case['signatures']; + if ( array() === $seed_signatures ) { + fwrite( STDERR, "Seed {$seed} produced no failure; nothing to minimize.\n" ); + exit( 1 ); + } } else { $selector = option_string( $options, 'selector', null ); $html = option_string( $options, 'html', null ); @@ -61,14 +77,66 @@ $baseline = $signatures_of( $selector, $html ); if ( array() === $baseline ) { fwrite( STDERR, "The starting pair does not reproduce any self-contained failure.\n" ); + if ( null !== $seed_signatures ) { + fwrite( STDERR, 'Seed failure(s): ' . implode( ', ', $seed_signatures ) . "\n" ); + fwrite( STDERR, "These are generator-side signatures the minimizer cannot reproduce from the\n" ); + fwrite( STDERR, "pair alone. Minimize a seed whose failure is self-contained, or pass\n" ); + fwrite( STDERR, "--selector/--html directly.\n" ); + } fwrite( STDERR, 'selector: ' . printable_bytes( $selector ) . "\n" ); exit( 1 ); } -// Pick the target signature. -$target = $baseline[0]; +/* + * Candidate targets are matched at the INVARIANT level, not the exact + * signature hash: a signature embeds transform-specific detail ( e.g. + * metamorphic-parse via `rerender` vs via `dup-branch` ), and run_pair's + * fixed metamorphic draws may expose the same invariant through a + * different transform than run_case did. Same invariant == same bug class, + * so that is faithful. A DIFFERENT invariant ( e.g. the seed's generator- + * side ast-mismatch vs an incidental self-contained metamorphic-ast ) is a + * genuine retarget and must be opted into. + */ +$invariant_of = static function ( string $signature ): string { + $pos = strrpos( $signature, ':' ); + return false === $pos ? $signature : substr( $signature, $pos + 1 ); +}; + +$retargeted = false; +if ( null === $seed_signatures ) { + $candidates = $baseline; +} else { + $seed_invariants = array_map( $invariant_of, $seed_signatures ); + $candidates = array(); + foreach ( $baseline as $signature ) { + if ( in_array( $invariant_of( $signature ), $seed_invariants, true ) ) { + $candidates[] = $signature; + } + } +} + +if ( array() === $candidates ) { + // The seed's failures are all generator-side ( no self-contained + // invariant in common ); refuse to silently minimize an unrelated + // incidental signature. + fwrite( STDERR, "Seed {$seed}'s failures are not self-contained, so the minimizer cannot\n" ); + fwrite( STDERR, "faithfully reproduce them.\n" ); + fwrite( STDERR, 'Seed failure(s): ' . implode( ', ', $seed_signatures ) . "\n" ); + fwrite( STDERR, 'Self-contained nearby: ' . implode( ', ', $baseline ) . "\n" ); + fwrite( STDERR, "Re-run with --signature to minimize one of the nearby signatures\n" ); + fwrite( STDERR, "explicitly ( understanding it is a related, not identical, failure ).\n" ); + if ( null === $sig_filter ) { + exit( 1 ); + } + // User explicitly opted into a nearby signature. + $candidates = $baseline; + $retargeted = true; +} + +// Pick the target signature from the eligible candidates. +$target = $candidates[0]; if ( null !== $sig_filter ) { - foreach ( $baseline as $candidate ) { + foreach ( $candidates as $candidate ) { if ( false !== strpos( $candidate, $sig_filter ) ) { $target = $candidate; break; @@ -165,6 +233,8 @@ static function ( string $candidate ) use ( $reproduces, &$html ): bool { echo json_encode_safe( array( 'target' => $target, + 'retargeted' => $retargeted, + 'seedSignatures' => $seed_signatures, 'reproduced' => $ok, 'attempts' => $attempts, 'selector' => printable_bytes( $selector ), @@ -179,6 +249,10 @@ static function ( string $candidate ) use ( $reproduces, &$html ): bool { } echo "target: {$target}\n"; +if ( $retargeted ) { + echo 'NOTE: seed failure(s) ' . implode( ', ', $seed_signatures ) . " are generator-side;\n"; + echo " minimized the related self-contained signature above instead.\n"; +} echo 'reproduced: ' . ( $ok ? 'yes' : 'NO' ) . "\n"; echo "attempts: {$attempts}\n"; echo 'selector: ' . printable_bytes( $selector ) . ' (' . strlen( $selector ) . " bytes)\n"; From 5f0ad7ce06efc812dbccaa63da7f8c8471edd2b8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:39:06 +0200 Subject: [PATCH 166/336] CSS selector fuzz: make lexbor oracle death observable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewers noted the lexbor tally was computed then discarded and the 'off' state conflated 'oracle not applicable' with 'oracle dead', so a third oracle that died mid-run (PHP latches available()=false and every later case silently returns 'off') would leave no trace in artifacts. Split the state into 'n/a' (unparseable selector / fragment / no tree — differential genuinely does not apply) vs 'unavailable' (harness missing or died) and 'error'. Aggregate the per-case tally into the batch summary (already present) and now into runner state.json under 'lexbor'. The runner prints a loud WARNING if unavailable/error appear after the harness had been live, or a NOTE if it never ran at all. Verified both paths. --- tools/css-selector-fuzz/README.md | 11 ++++++++++- tools/css-selector-fuzz/lib/Worker.php | 12 +++++++++--- tools/css-selector-fuzz/runner.php | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 2c60bf5c73a51..c48c790e3fdc9 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -88,7 +88,16 @@ produces the same document, the same selector, and the same verdict. Build with `sh tools/css-selector-fuzz/lexbor/build.sh` (clones and builds liblexbor, pinned to v3.0.0 = `2ae88a1c6b52`). The worker auto-detects the binary at `tools/css-selector-fuzz/lexbor/harness` and reports per-batch -tallies (`compared` / `tree-gated` / `skipped-quirks` / `off`). +tallies, persisted to `state.json` under `lexbor`: + +- `compared` — the differential ran and matched fid-multisets. +- `tree-gated` — WP and lexbor built different trees; differential skipped. +- `skipped-quirks` / `skipped-utf8` — quirks document / non-UTF-8 AST. +- `n/a` — the differential does not apply (unparseable selector, fragment, no + captured tree). +- `unavailable` / `error` — the harness was missing or died. The runner prints + a loud warning if these appear after the harness had run, so a third oracle + that dies mid-run cannot hide behind a green run. Known lexbor issues compensated for at this pin: diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 6371b617cc028..b2ad002ed8ac0 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -256,7 +256,12 @@ static function () use ( $complex_list ) { // --- Match phase --------------------------------------------------- $html_matches = null; - $lexbor_state = 'off'; + // 'n/a' = the lexbor differential does not apply to this case + // ( unparseable selector, fragment, no captured tree ). Distinct from + // 'unavailable', which check_lexbor_differential reports only when the + // harness itself is missing or died — so a silently-dropped third + // oracle shows up in the per-batch tally instead of hiding in 'off'. + $lexbor_state = 'n/a'; if ( null !== $complex_ast && null !== $rows ) { $expected = ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, $quirks ); @@ -729,11 +734,12 @@ private static function check_select_matches( string $target, string $selector_s * means reference == lexbor != WP: a * high-confidence WP finding. * - * @return string Tally state: off|skipped-quirks|error|tree-gated|compared. + * @return string Tally state: + * unavailable|skipped-quirks|skipped-utf8|error|tree-gated|compared. */ private static function check_lexbor_differential( array $complex_ast, string $selector_string, array $document, array $rows, bool $quirks, array $expected, callable $record ): string { if ( ! LexborOracle::available() ) { - return 'off'; + return 'unavailable'; } if ( $quirks ) { return 'skipped-quirks'; diff --git a/tools/css-selector-fuzz/runner.php b/tools/css-selector-fuzz/runner.php index 3dbdf0ee66cbe..fc3db262b282a 100644 --- a/tools/css-selector-fuzz/runner.php +++ b/tools/css-selector-fuzz/runner.php @@ -158,6 +158,7 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { 'crashes' => 0, 'buckets' => array(), 'signatures' => array(), + 'lexbor' => array(), 'nextSeed' => $start_seed, 'stopReason' => null, ); @@ -244,6 +245,9 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { foreach ( $summary['signatures'] as $signature => $signature_count ) { $state['signatures'][ $signature ] = ( $state['signatures'][ $signature ] ?? 0 ) + $signature_count; } + foreach ( $summary['lexbor'] ?? array() as $lexbor_state => $lexbor_count ) { + $state['lexbor'][ $lexbor_state ] = ( $state['lexbor'][ $lexbor_state ] ?? 0 ) + $lexbor_count; + } } $seed += $count; @@ -263,5 +267,20 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { $state['updatedAt'] = gmdate( 'c' ); write_json_file( $state_path, $state ); +/* + * The lexbor differential is the third oracle. If it ever ran ( 'compared' ) + * it was built and live; any 'unavailable' or 'error' tally then means it + * was missing for some cases or died mid-run, so part of the run had only + * two oracles. Surface that loudly rather than letting a green run hide it. + */ +$lexbor = $state['lexbor']; +$lexbor_ran = ( $lexbor['compared'] ?? 0 ) > 0; +$lexbor_lost = ( $lexbor['unavailable'] ?? 0 ) + ( $lexbor['error'] ?? 0 ); +if ( $lexbor_ran && $lexbor_lost > 0 ) { + fwrite( STDERR, "WARNING: lexbor third oracle was unavailable/errored for {$lexbor_lost} case(s); those ran with two oracles.\n" ); +} elseif ( ! $lexbor_ran ) { + fwrite( STDERR, "NOTE: lexbor third oracle never ran (harness not built?); run `sh tools/css-selector-fuzz/lexbor/build.sh` for the differential.\n" ); +} + echo json_encode_safe( $state ) . "\n"; exit( 0 === $state['failures'] ? 0 : 2 ); From 031d5c38a08c5b02180d0b74f548ac8514a99543 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:40:39 +0200 Subject: [PATCH 167/336] CSS selector fuzz: harden lexbor harness (base64 + fid framing) Two latent issues from the C review (neither reachable from the current generators, both defensive): - b64_decode had dead code (two empty if-bodies) and a comment claiming invalid bytes are skipped when they were actually folded into the output stream as zero, corrupting everything after a stray byte. Now genuinely skip any non-alphabet byte (testing the byte, not its table value, since 'A' legitimately maps to 0). The index is unsigned, so table[c] was never out of bounds. - put_fid wrote the raw data-fid into the tab-and-newline framed protocol; a fid containing TAB/LF/CR would desync row/match parsing on the PHP side. Generated fids are always w/e, but harden anyway: the harness replaces those three bytes with '?', and TreeCapture::sanitize_fid applies the identical replacement to WP's own fids, so a sanitized fid still compares equal (worst case a benign tree-gated skip, never a false divergence). Verified a tab-fid produces one row/match/D with no desync. --- tools/css-selector-fuzz/lexbor/harness.c | 33 +++++++++++++++++---- tools/css-selector-fuzz/lib/TreeCapture.php | 13 +++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/tools/css-selector-fuzz/lexbor/harness.c b/tools/css-selector-fuzz/lexbor/harness.c index a33a198090ddf..ebd3aa32f4b4a 100644 --- a/tools/css-selector-fuzz/lexbor/harness.c +++ b/tools/css-selector-fuzz/lexbor/harness.c @@ -65,13 +65,18 @@ b64_decode(const char *in, size_t in_len, size_t *out_len) for (size_t i = 0; i < in_len; i++) { unsigned char c = (unsigned char) in[i]; + /* + * The PHP adapter always feeds well-formed base64_encode() output, + * but guard anyway: skip padding/whitespace, and actually skip any + * byte not in the alphabet ('A' legitimately maps to 0, so test the + * byte itself, not its table value). c is unsigned, so table[c] is + * always in bounds. + */ if (c == '=' || c == '\n' || c == '\r') { continue; } - if (c != 'A' && table[c] == 0 && c != 'A') { - if (c != 'A') { - /* invalid chars are skipped; base64 here is machine-made */ - } + if (c != 'A' && table[c] == 0) { + continue; } acc = (acc << 6) | (unsigned int) table[c]; bits += 6; @@ -97,6 +102,24 @@ put_upper(const lxb_char_t *name, size_t len) } } +/* + * Emit a data-fid value, replacing the framing bytes TAB / LF / CR with '?'. + * Generated documents only ever use fids like "w12" / "e3", so this never + * fires in practice; it guards the line-and-tab protocol against a fid that + * contains a control char (which would otherwise desync row/match parsing on + * the PHP side). LexborOracle applies the identical replacement when reading + * WP's own fids, so a sanitized fid still compares equal — the worst case is + * a benign tree-gated skip, never a false divergence. + */ +static void +put_fid_value(const lxb_char_t *value, size_t value_len) +{ + for (size_t i = 0; i < value_len; i++) { + unsigned char c = value[i]; + putchar((c == '\t' || c == '\n' || c == '\r') ? '?' : c); + } +} + static void put_fid(lxb_dom_node_t *node) { @@ -106,7 +129,7 @@ put_fid(lxb_dom_node_t *node) element, (const lxb_char_t *) "data-fid", 8, &value_len); if (value != NULL) { - fwrite(value, 1, value_len, stdout); + put_fid_value(value, value_len); return; } diff --git a/tools/css-selector-fuzz/lib/TreeCapture.php b/tools/css-selector-fuzz/lib/TreeCapture.php index bd64dc30eb6de..616a3ff65ba76 100644 --- a/tools/css-selector-fuzz/lib/TreeCapture.php +++ b/tools/css-selector-fuzz/lib/TreeCapture.php @@ -110,7 +110,18 @@ public static function capture( string $html, ?string $context = null ): array { /** The element's data-fid, or the same placeholder collect_matches() uses. */ private static function fid_of( $processor ): string { $fid = $processor->get_attribute( 'data-fid' ); - return is_string( $fid ) ? $fid : '(missing-fid:' . $processor->get_tag() . ')'; + return is_string( $fid ) ? self::sanitize_fid( $fid ) : '(missing-fid:' . $processor->get_tag() . ')'; + } + + /** + * Replaces the lexbor protocol framing bytes ( TAB / LF / CR ) in a fid + * with '?'. Generated fids never contain these, but the lexbor harness + * applies the same replacement, so matching this here keeps the two trees + * comparable even for a hypothetical control-char fid ( the worst case is + * a benign tree-gated skip, never a false divergence ). + */ + public static function sanitize_fid( string $fid ): string { + return strtr( $fid, "\t\n\r", '???' ); } /** From a5f832e938b5354540ec3823fc4c2a0f14b31d63 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:42:31 +0200 Subject: [PATCH 168/336] CSS selector fuzz: make class-selector coverage deterministic Reviewer found COVERAGE.md's documented command gave 375/401, not the reported 376/401: class-wp-css-class-selector.php:58 (the bare/trailing '.' guard) is reachable but was only sampled intermittently (it landed at seeds 4001-4050, outside the 3000-seed window), so the report's 'all 25 unreached lines accounted for' was off by one. Add the 'a.' invalid template so that guard is hit deterministically within the documented window. class-selector is now a reproducible 10/10 and the 376/401 (93.8%) total matches the documented command exactly; the 25 unreached lines are the 12 case-label artifacts + 13 defensive guards already itemized. --- tools/css-selector-fuzz/COVERAGE.md | 9 ++++++--- tools/css-selector-fuzz/lib/SelectorGenerator.php | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/css-selector-fuzz/COVERAGE.md b/tools/css-selector-fuzz/COVERAGE.md index e69e34cf38a17..5c772ce27793c 100644 --- a/tools/css-selector-fuzz/COVERAGE.md +++ b/tools/css-selector-fuzz/COVERAGE.md @@ -70,6 +70,9 @@ guarantees, or branches for grammar the parser never emits: (`consume_escaped_codepoint` for NUL / surrogate / over-max codepoints) and the `normalize_selector_input` NUL→U+FFFD and CR/CRLF/FF→LF paths, which the structural generators cannot reach. -- A few `invalid`-bucket templates (`[ a`, `[a="x\`) were added to reach - attribute/string parse guards that random structural generation rarely - lands on. +- A few `invalid`-bucket templates (`[ a`, `[a="x\`, `a.`) were added to reach + attribute / string / class parse guards that random structural generation + rarely lands on. With them the per-file numbers above are **deterministic** + at the documented 3000-seed window (e.g. `class-wp-css-class-selector.php` + reaches 10/10 reliably rather than depending on whether a bare `.` happened + to be sampled). diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 7bf1851f192e6..88a2cbaefd9a9 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -1273,6 +1273,7 @@ private function gen_invalid(): string { ' ', "\t\n\f ", '.', + 'a.', '#', '[', ']', From acd1762d1861fdcd27ce9b80834a4b3c29745fa0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 14:44:12 +0200 Subject: [PATCH 169/336] CSS selector fuzz: honest framing of oracle strength Reviewers flagged overclaims in the prose. Corrected with measured numbers, no behavior change: - 'most match assertions non-vacuous' -> true only within the path-directed bucket (~67% non-vacuous); aggregate across all buckets is ~62% vacuous [] == [] by design (negative-oriented and parse-focused buckets are intentionally empty-set). README, FINDINGS, NEXT-STEPS now state both numbers and the actual point (the combinator walker is exercised with real depth). - 'three oracles agree on every compared case' -> kept, but disclose ~half of compared cases are themselves vacuous. - Quirks-mode class/ID matching has NO independent third oracle (lexbor #368 excludes quirks docs), and ReferenceMatcher shares WP's ASCII-fold reading by construction -> documented as the weakest-covered behavior rather than implied-covered. - The determinism digest covers the WP-under-test surface but not the lexbor oracle's own output -> noted. --- tools/css-selector-fuzz/FINDINGS.md | 5 +++++ tools/css-selector-fuzz/NEXT-STEPS.md | 5 ++++- tools/css-selector-fuzz/README.md | 29 ++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index 302d6218a6f0a..4d5331df629a6 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -9,6 +9,11 @@ No new bugs surfaced beyond these three, and no fuzzer-side (oracle or generator) defect surfaced: with all three fixes applied a 5000-seed run is completely clean, and the lexbor differential (third independent oracle) agreed with the reference matcher on every compared no-quirks case (0 `lexbor-divergence`). +Caveats on the strength of that agreement: roughly half of the `compared` +cases (and ~62% of all match assertions across buckets) are vacuous `[] == []`; +quirks-mode class/ID matching is excluded from the differential (lexbor #368) +and so rests on `ReferenceMatcher` alone. See `README.md` for the full +disclosure. Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '' [--html '']`. Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed ` diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 86fa37c61eb93..5d7a76e51dc8c 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -167,7 +167,10 @@ keep-failing) to a minimal reproducer. Wire into `replay.php` or a new a fuzzer-oracle fix — never left ambiguous. - Metamorphic invariants in place and passing. - Positive-match rate for combinator selectors materially raised (path-directed - generation); match assertions are mostly non-vacuous. + generation): ~68% in that bucket vs ~14% before, so the combinator/breadcrumb + walker is genuinely exercised. (Aggregate across all buckets remains ~62% + vacuous `[] == []`, by design — the negative-oriented and parse-focused + buckets are intentionally mostly empty-set; see `README.md`.) - Minimizer produces minimal repros automatically. - A clean multi-thousand-seed run with all signatures triaged; `FINDINGS.md` updated with any new bugs (each with a minimal repro and a one-line fix diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index c48c790e3fdc9..92c0ce3f10579 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -35,9 +35,16 @@ produces the same document, the same selector, and the same verdict. match that element — or flipped into a near-miss (wrong type/class/attr guarantees a non-match; loosening `>` to descendant must keep matching). The guarantee is asserted against the reference matcher - (`path-expectation`), making most match assertions non-vacuous: - measured positive-match rate for combinator selectors is ~68% in this - bucket vs ~14% in `supported-complex`. + (`path-expectation`). Within this bucket ~67% of match assertions are + non-vacuous (positive-match rate ~68% for combinator selectors, vs ~14% + in `supported-complex`). Across *all* buckets ~38% of match assertions + are non-vacuous: the negative-oriented buckets (`unsupported`, + `invalid`, much of `supported-*`) and `edge-escape` (which targets the + parse/escape-decode path, not matching) are intentionally mostly + empty-set, so the aggregate `[] == []` rate is ~62%. The point of + path-directed generation is that the *combinator/breadcrumb* walker — + the part most likely to harbor a matching bug — is now exercised with + real depth, not that every assertion is non-vacuous. - `unsupported` — valid CSS the API intentionally rejects (pseudo-classes and -elements, `+`/`~`/`||` combinators, namespaces, non-type context selectors); must not parse. @@ -80,8 +87,13 @@ produces the same document, the same selector, and the same verdict. the selector layer, not tree construction. Verdicts: `lexbor-divergence` (lexbor ≠ reference) is a fuzzer-oracle problem; `match-mismatch-html` with no accompanying divergence means reference == lexbor ≠ WP — a - high-confidence WP finding. + high-confidence WP finding. (Roughly half of `compared` cases are + themselves non-vacuous; the rest assert `[] == []` on both engines.) - Repeating a case yields a byte-identical result digest (determinism). + Note the digest covers the WP-under-test surface (selector, html, + parse-nullness, ASTs, failure invariants) but **not** the lexbor + oracle's own output, so it would not flag a flaky lexbor result that + never escalates to a `lexbor-divergence` failure. ## lexbor harness @@ -107,7 +119,14 @@ Known lexbor issues compensated for at this pin: case-sensitive). Detected by a startup probe; when present, lexbor is compared against the reference matcher run with quirks-style class/ID folding, and quirks-mode documents are excluded from the differential - entirely (the reference matcher is the sole quirks authority). + entirely. **Consequence — a real coverage hole:** quirks-mode class/ID + matching has no independent third oracle. `ReferenceMatcher` is the sole + authority there, and it encodes the same "ASCII-only case fold in quirks" + reading WP does (both fold via ASCII-only lowercasing), so if that reading + is wrong they would be wrong identically and lexbor — the one engine that + could disagree — is excluded. This is inherent to lexbor #368 being open; + it is the weakest-covered behavior in the suite and is called out here + rather than papered over. - lexbor rejects uppercase `I`/`S` attribute-selector modifiers, and its non-ASCII ident-codepoint table omits U+00B7 and U+00C0–U+00F6 (it starts at U+00F8), rejecting e.g. `.Über` while accepting `.über`. From 86744400159a9bd5e5ba8f2e32f11d46bb5dc0e4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 15:00:36 +0200 Subject: [PATCH 170/336] CSS selector fuzz: model class_list NUL-fold; close review nitpicks Reviewer-1 #10 (class-value decode boundary): ReferenceMatcher::class_matches now folds NUL -> U+FFFD per class token, matching WP_HTML_Tag_Processor:: class_list(). Previously the reference left raw NUL, so it would have diverged from select() on a class value containing NUL (a reference gap, not a WP bug). Pinned by five deterministic decode-boundary cases in self-check.php (NUL->FFFD, trailing NUL, FF separator) that put the two independent class tokenizers against each other and against select(). Randomized document-side NUL injection is deliberately NOT added: a hot-path PRNG draw perturbs self-check.php's fixed seed space enough to surface the known Bug 3, which would first require decoupling self-check from the unfixed core bugs. Documented as a scoped future improvement in README 'Known oracle limitations', which also distinguishes the independent class-value path from the shared get_attribute() attribute path per the reviewer's sharpening. Reviewer-2 nitpick (fid sanitization asymmetric on match path): collect_matches now routes select() fids through TreeCapture::sanitize_fid, identical to the tree-row and lexbor sides, so a control-char fid can never produce a false lexbor-divergence (unreachable today; fids are integers). Reviewer-3 nitpick: minimize.php header docstring no longer implies --seed works universally. Validated: self-check OK (incl. new cases), unpatched detects the 3 bugs, patched 2000-seed run clean (927 lexbor comparisons), all 3 bugs still reproduce. --- tools/css-selector-fuzz/README.md | 23 ++++++++++++ .../lib/ReferenceMatcher.php | 8 +++++ tools/css-selector-fuzz/lib/Worker.php | 5 ++- tools/css-selector-fuzz/minimize.php | 15 +++++--- tools/css-selector-fuzz/tests/self-check.php | 36 +++++++++++++++++++ 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 92c0ce3f10579..661e6dd575cba 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -136,6 +136,29 @@ Known lexbor issues compensated for at this pin: - `lxb_selectors_find` reports a node once per matching selector-list branch; `LXB_SELECTORS_OPT_MATCH_FIRST` dedupes. +## Known oracle limitations (document-side decoding) + +The match oracle's independence differs between class and attribute selectors: + +- **Class values are matched by two genuinely independent tokenizers.** WP's + `select('.x')` goes through `WP_HTML_Tag_Processor::class_list()`, which + splits on ASCII whitespace and folds NUL → U+FFFD per token; + `ReferenceMatcher::class_matches()` reimplements that independently (and is + pinned against `class_list()` on NUL/FF boundary inputs by `self-check.php`). + The random document generators do **not** emit control bytes inside class + values, so the *randomized* fuzzing never exercises this boundary — it is + covered only by the deterministic self-check cases. Randomized document-side + injection is deliberately deferred: adding it to the hot path perturbs the + deterministic self-check seed space enough to surface the known Bug 3, which + would first require decoupling `self-check.php` from the unfixed core bugs. + A worthwhile, scoped future improvement. +- **Attribute values are matched through a single shared read.** Both WP's + attribute matcher and `ReferenceMatcher::attr_matches()` read the same + `get_attribute()` output, so a value-decoding bug there would be shared and + invisible regardless of input — a genuine shared-oracle limitation that no + generator change can close (it needs an independent attribute-value decoder, + which lexbor partly provides on no-quirks documents). + ## Usage Bounded fuzz run (process-isolated chunks, crash/hang attribution): diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php index 6af3301cd080b..5acbf9f5d4927 100644 --- a/tools/css-selector-fuzz/lib/ReferenceMatcher.php +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -172,6 +172,14 @@ private static function class_matches( string $wanted, array $row, bool $quirks $word = substr( $class_value, $at, $word_length ); $at += $word_length; + /* + * WP_HTML_Tag_Processor::class_list() replaces NUL with U+FFFD in + * each class token before comparison; model that so a class value + * containing a raw NUL matches a `\0`-escaped ( U+FFFD ) selector + * the same way select() does. + */ + $word = str_replace( "\0", "\u{FFFD}", $word ); + if ( $quirks ? ascii_strtolower( $word ) === ascii_strtolower( $wanted ) diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index b2ad002ed8ac0..5bb607a032c58 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -649,7 +649,10 @@ static function () use ( $target, $selector_string, $html, $context ) { $iterations = 0; while ( $processor->select( $selector_string ) ) { $fid = $processor->get_attribute( 'data-fid' ); - $matches[] = is_string( $fid ) ? $fid : '(missing-fid:' . $processor->get_tag() . ')'; + // Sanitize identically to TreeCapture/lexbor so a fid with + // a control char can never produce a false divergence on + // the match path ( unreachable today: fids are integers ). + $matches[] = is_string( $fid ) ? TreeCapture::sanitize_fid( $fid ) : '(missing-fid:' . $processor->get_tag() . ')'; if ( ++$iterations > self::SELECT_ITERATION_LIMIT ) { throw new \RuntimeException( 'select() did not terminate within the iteration limit.' ); } diff --git a/tools/css-selector-fuzz/minimize.php b/tools/css-selector-fuzz/minimize.php index edfc892689fa7..c7fe1f1cb3dda 100644 --- a/tools/css-selector-fuzz/minimize.php +++ b/tools/css-selector-fuzz/minimize.php @@ -9,9 +9,13 @@ * * The minimizer drives Worker::run_pair, which checks only self-contained * invariants (computable from the pair alone), so it needs no generator - * intent. The three known bugs reduce to self-contained signatures: - * Bug 1 -> metamorphic-ast, Bug 2 -> match-mismatch-html, Bug 3 -> - * metamorphic-parse. + * intent. --seed faithfully minimizes only seeds whose failure is + * self-contained; the generator-side invariants (ast-mismatch, + * parse-expectation, path-expectation, model-desync) are invisible to + * run_pair, so a seed whose failure is only those is refused by default + * (each of the three known bugs DOES also surface a self-contained + * signature — Bug 1 -> metamorphic-ast, Bug 2 -> match-mismatch-html, + * Bug 3 -> metamorphic-parse — reachable via --signature). * * Usage: * php tools/css-selector-fuzz/minimize.php --seed 1234 [--signature SUBSTR] @@ -19,8 +23,9 @@ * * Options: * --signature SUBSTR Target a signature whose id or invariant contains - * SUBSTR (default: the first signature of the seed's - * failure set). + * SUBSTR. For --seed, also the way to opt into a + * related self-contained signature when the seed's own + * failure is generator-side (printed as a retarget). * --max-attempts N Cap test evaluations (default 4000). * --json Emit the reproducer as JSON. */ diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index e92cc3ecd3f7c..10196f2d62291 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -115,6 +115,42 @@ function select_fids( string $html, string $selector ): array { check( array( 'e4' ) === select_fids( $known_html, '[data-v|="hello"]' ), 'Known: [data-v|=hello].' ); check( array( 'e7' ) === select_fids( $known_html, '[lang^="en"]' ), 'Known: [lang^=en].' ); +// --- Class-value decode boundary (ReferenceMatcher vs WP class_list) -------- +// WP's class_list() folds NUL -> U+FFFD and treats FF as a separator; the +// reference matcher reimplements tokenization independently. Pin both engines +// against each other on these boundary inputs ( exercised deterministically +// here since the random document generator does not emit control bytes in +// class values — see README #10 ). Each case also checks the reference matcher +// agrees with select() over a TreeCapture of the same markup. + +function ref_fids( string $html, string $selector ): array { + $capture = \CssSelectorFuzz\TreeCapture::capture( $html ); + $list = WP_CSS_Complex_Selector_List::from_selectors( $selector ); + if ( null !== $capture['error'] || null === $list ) { + return array( '(error)' ); + } + $ast = \CssSelectorFuzz\AstExtractor::from_complex_list( $list ); + return \CssSelectorFuzz\ReferenceMatcher::expected_html_matches_rows( $ast, $capture['htmlRows'], $capture['quirks'] ); +} + +$nul_html = ""; +$ff_html = ""; + +$nul_cases = array( + array( "class NUL -> FFFD", $nul_html, ".foo\u{FFFD}bar", array( 'n0' ) ), + array( "class trailing NUL", $nul_html, ".x\u{FFFD}", array( 'n1' ) ), + array( "class raw NUL no-match", $nul_html, '.foobar', array() ), + array( "class FF separator (first)", $ff_html, '.alpha', array( 'f0' ) ), + array( "class FF separator (second)", $ff_html, '.beta', array( 'f0' ) ), +); +foreach ( $nul_cases as $case ) { + list( $label, $html, $selector, $expected ) = $case; + $wp = select_fids( $html, $selector ); + $ref = ref_fids( $html, $selector ); + check( $expected === $wp, "Decode boundary ({$label}): select() == expected." ); + check( $ref === $wp, "Decode boundary ({$label}): ReferenceMatcher == select()." ); +} + // --- Worker end-to-end on a few seeds --------------------------------------- for ( $seed = 1; $seed <= 5; $seed++ ) { From acbc6a1a4e52edbdfb0d99b74326fae73fe473ae Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 15:04:10 +0200 Subject: [PATCH 171/336] CSS selector fuzz: track self-check/known-bug decoupling as follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-1 forward-looking note (not a blocker): self-check.php's parse-expectation loop runs over a fixed seed window that dodges the three known core bugs only by seed luck, so any future generator change can collide with them (as the deferred class-NUL injection already does). Record decoupling self-check from the unfixed core bugs as a standalone hardening item in NEXT-STEPS — worth doing independently and the prerequisite for randomized class-NUL injection. --- tools/css-selector-fuzz/NEXT-STEPS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 5d7a76e51dc8c..0081ce4d3a63a 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -8,6 +8,17 @@ > raised from 14.5% to ~68% (path-directed bucket); minimizer working; a clean > 5000-seed run with all signatures triaged to the three known bugs, all of > which still reproduce. The notes below are retained as the design rationale. +> +> **Open follow-up hardening (post-review):** `tests/self-check.php` runs its +> parse-expectation assertions over a fixed seed window (1–400) that currently +> dodges the three known core bugs only by seed luck. Any generator change that +> shifts the PRNG stream can collide with Bug 1/3 there (it already does for the +> deferred document-side class-NUL injection — see README "Known oracle +> limitations"). Decouple self-check from the unfixed core bugs — e.g. apply the +> three FINDINGS.md fixes inside the self-check harness, or allowlist their +> signatures in the parse-expectation loop — as a standalone hardening. This is +> worth doing on its own (it makes self-check robust to *any* future generator +> change) and is the prerequisite for randomized class-NUL document injection. Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). From 0a5ef71b4443e422dcd1bb29a356a7aad25ff70a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 17:28:57 +0200 Subject: [PATCH 172/336] CSS selector fuzz: record fix status; correct findings inaccuracies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three FINDINGS.md bugs are now fixed on this branch by the 'CSS selector:' commits, each with PHPUnit regression tests, and a post-fix 5000-seed run is clean. Update FINDINGS.md and the NEXT-STEPS regression anchors accordingly: the minimal repros must now NOT trigger. Corrections and additions surfaced by the fix-review panels: - Bug 2's claimed substr_compare negative-length edge does not exist (-strlen('') is 0, and PHP clamps out-of-range negative offsets); the landed fix also excludes ~= from the guard since a whitespace-delimited list never yields an empty item. - README: note lexbor's [x~=""] whitespace-value divergence (matches where Selectors-4/Chrome/WP say no match) as a latent noise source and candidate upstream report. - NEXT-STEPS: candidate finding 4 — \ at EOF is a valid escape per CSS Syntax §4.3.8 and should decode to U+FFFD in ident context, but WP rejects it ('.foo\' parses to null). Unverified, low severity. - NEXT-STEPS: self-check/known-bug decoupling reframed now that this branch's core is fixed; hazard remains for unfixed-core runs. --- tools/css-selector-fuzz/FINDINGS.md | 22 +++++++++++------ tools/css-selector-fuzz/NEXT-STEPS.md | 34 +++++++++++++++++++-------- tools/css-selector-fuzz/README.md | 6 +++++ 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index 4d5331df629a6..5e650aac73549 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -5,6 +5,12 @@ seeds, 0 crashes/timeouts. Three distinct, reproduced WordPress-core correctness bugs in the new HTML-API CSS selector support. Every selector below is valid, supported CSS that the API mis-handles **without** reporting lack of support. +**Status: all three bugs are fixed on this branch** (commit prefix +`CSS selector:` — Bug 1 `7419a9fef6`, Bug 2 `0cefeb2fc8`, Bug 3 `16d03e2c5f`), +each with PHPUnit regression tests that fail pre-fix. A post-fix 5000-seed run +is clean (0 failures, 0 crashes). The repros below no longer trigger; they +remain as regression anchors and Trac-ready minimal test cases. + No new bugs surfaced beyond these three, and no fuzzer-side (oracle or generator) defect surfaced: with all three fixes applied a 5000-seed run is completely clean, and the lexbor differential (third independent oracle) agreed @@ -54,9 +60,8 @@ non-hex identity-escape branch is wrong. Depending on what wrong codepoint is produced this also causes spurious parse failures (a valid selector returns `null`). -**Fix direction:** read the next codepoint by byte offset, e.g. -`mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' )`, or decode the UTF-8 -lead byte length from `$input[$offset]` directly. +**Fix (landed in `7419a9fef6`):** read the next codepoint from the byte +offset: `mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' )`. --- @@ -82,9 +87,11 @@ Reproduction against ``: | `[x$=""]` | `I` | none | | `[x~=""]` | none ✅ | none | -**Fix direction:** in `matches()`, return `false` for `^= $= *=` (and `~=`) when -`'' === $this->value`, before the `substr_compare`/`strpos` calls. (This also -removes a `substr_compare` negative-length edge with very short attribute values.) +**Fix (landed in `0cefeb2fc8`):** in `matches()`, return `false` for `^= $= *=` +when `'' === $this->value`, before the `substr_compare`/`strpos` calls. `~=` +needs no guard — a whitespace-delimited list never yields an empty item — and +a test pins that. (No `substr_compare` length edge exists here: `-strlen('')` +is `0`, and PHP clamps out-of-range negative offsets rather than erroring.) --- @@ -115,7 +122,8 @@ character of the selector string. | `[a^=b]` | parsed ✅ (2-char operator) | | `[a=b].c` | parsed ✅ (trailing content) | -**Fix direction:** change `>=` to `>` (need `strlen - $updated_offset >= 3`). +**Fix (landed in `16d03e2c5f`):** change `>=` to `>` (need +`strlen - $updated_offset >= 3`). --- diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 0081ce4d3a63a..51a68dc06a5cf 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -9,16 +9,29 @@ > 5000-seed run with all signatures triaged to the three known bugs, all of > which still reproduce. The notes below are retained as the design rationale. > +> **Core fixes landed:** the three FINDINGS.md bugs are fixed on this branch +> (`CSS selector:` commits `7419a9fef6` / `0cefeb2fc8` / `16d03e2c5f`), each +> with PHPUnit regression tests. A post-fix 5000-seed run is clean. +> > **Open follow-up hardening (post-review):** `tests/self-check.php` runs its -> parse-expectation assertions over a fixed seed window (1–400) that currently -> dodges the three known core bugs only by seed luck. Any generator change that -> shifts the PRNG stream can collide with Bug 1/3 there (it already does for the -> deferred document-side class-NUL injection — see README "Known oracle -> limitations"). Decouple self-check from the unfixed core bugs — e.g. apply the -> three FINDINGS.md fixes inside the self-check harness, or allowlist their +> parse-expectation assertions over a fixed seed window (1–400) that, against +> an *unfixed* core, dodges the known core bugs only by seed luck. On this +> branch the bugs are fixed so the collision risk is gone, but the hazard +> returns whenever the tooling runs against a core without the fixes (e.g. +> cherry-picked onto trunk before the fixes land) or when a future unfixed bug +> is found. Decouple self-check from unfixed core bugs — e.g. allowlist known > signatures in the parse-expectation loop — as a standalone hardening. This is > worth doing on its own (it makes self-check robust to *any* future generator > change) and is the prerequisite for randomized class-NUL document injection. +> +> **Candidate finding 4 (unverified, found in fix review):** per CSS Syntax 3 +> §4.3.8, `\` followed by EOF is a valid escape (EOF is not a newline), and +> §4.3.7 says consuming it returns U+FFFD — so `.foo\` should parse as class +> `foo\u{FFFD}`. WP's `next_two_are_valid_escape()` requires a code point after +> the backslash, so `.foo\` is rejected (`from_selectors()` → null). The +> string-context behavior (`'foo\` → `foo`, "do nothing" at EOF) is already +> spec-correct; only ident context diverges. Low severity (fail-safe null, not +> a mis-match); verify against browsers, then fix or document as intentional. Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). @@ -189,7 +202,8 @@ keep-failing) to a minimal reproducer. Wire into `replay.php` or a new ## Existing bugs to keep verifying (regression anchors) -From `FINDINGS.md` — minimal repros, all must still trigger until core is fixed: -1. Identity escape after multibyte mis-decodes: `#Ü,\sup #x` → type `uup` (want `sup`). -2. Empty-value matchers match everything: `[x^=""]`, `[x*=""]`, `[x$=""]`. -3. Off-by-one length guard: `[a=b]` (single-char unquoted value, exact `=`, at EOF) → `null`. +From `FINDINGS.md` — all three are fixed on this branch and pinned by PHPUnit +tests; the minimal repros must now NOT trigger (a clean 5000-seed run confirms): +1. Identity escape after multibyte mis-decodes: `#Ü,\sup #x` → type must be `sup`. +2. Empty-value substring matchers: `[x^=""]`, `[x*=""]`, `[x$=""]` must match nothing. +3. Off-by-one length guard: `[a=b]` (single-char unquoted value, exact `=`, at EOF) must parse. diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 661e6dd575cba..3af425719c807 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -135,6 +135,12 @@ Known lexbor issues compensated for at this pin: findings. - `lxb_selectors_find` reports a node once per matching selector-list branch; `LXB_SELECTORS_OPT_MATCH_FIRST` dedupes. +- lexbor matches `[x~=""]` against whitespace-only attribute values + (e.g. `x=" "`); Selectors-4 and Chrome say an empty operand never + matches a list item, and WP agrees with them. Latent + `lexbor-divergence` noise source if the generator ever pairs `~=""` + with whitespace-valued attributes; candidate upstream report, not a + WP finding. ## Known oracle limitations (document-side decoding) From 68ef171dea1d9fae4b558345db6648cba7713259 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 18:46:50 +0200 Subject: [PATCH 173/336] CSS selector fuzz: draft upstream lexbor bug reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spec-conformance bugs in liblexbor v3.0.0, found via the differential oracle and re-verified directly against the harness: 1. [x~=""] matches whitespace-only attribute values (empty operand must never match, Selectors-4 §6.1). 2. Uppercase I/S attribute-selector modifiers are parse errors (Selectors-4 §6.3 defines them case-insensitively). 3. Non-ASCII ident code points below U+00F8 (U+00B7, U+00C0-U+00F6) are rejected; the table appears to start at U+00F8 (CSS Syntax 3 §4.2). Each comes with verified repro tables, control cases bracketing the boundary, spec citations, a self-contained C repro skeleton, and instructions for an agent to re-verify at lexbor master, dedupe against existing issues, and file upstream. lexbor #368 is already filed and excluded. --- .../lexbor/UPSTREAM-ISSUES.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md diff --git a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md new file mode 100644 index 0000000000000..8ebf17484b095 --- /dev/null +++ b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md @@ -0,0 +1,141 @@ +# lexbor — draft upstream bug reports + +Three spec-conformance bugs in liblexbor's CSS selectors support, found while +using lexbor as a differential oracle for the WordPress HTML-API CSS selector +fuzzer (`tools/css-selector-fuzz/`). All three were re-verified directly +against the harness on 2026-06-10. + +- **Pinned version:** lexbor v3.0.0 (`2ae88a1c6b52`), built by + `tools/css-selector-fuzz/lexbor/build.sh`. +- **Upstream repo:** https://github.com/lexbor/lexbor +- **Already filed upstream — do NOT refile:** + [#368](https://github.com/lexbor/lexbor/issues/368) (class/`#id` selectors + match ASCII case-insensitively in no-quirks documents). + +## Instructions for the filing agent + +1. **Re-verify at lexbor master first.** The pin is v3.0.0; any of these may + already be fixed. Edit `build.sh` to build master (or clone/build manually) + and re-run the repros below. Only file what still reproduces, and say in + the report which commit you tested. +2. **Search for duplicates** before filing (suggested queries: `~=`, + `attr-modifier`, `case insensitive modifier`, `ident code point`, + `U+00B7`, `non-ascii`). #368 shows the maintainer's preferred repro style. +3. **One issue per bug.** Reduce each to a self-contained C repro (sketch + below); maintainers should not need this repo's harness. +4. Reproduction via this repo (fast path): build the harness + (`sh tools/css-selector-fuzz/lexbor/build.sh`), then feed it + `base64(html) TAB base64(selector)` lines on stdin. Response lines per + case: `Rtagfidancestors` (tree rows), `Mfid` (match), + `Xreason` (selector parse error), terminated by `D`. See + `lib/LexborOracle.php` for a reference client and `harness.c` for the + exact lexbor API usage (`lxb_html_document_parse`, + `lxb_css_selectors_parse`, `lxb_selectors_find`). + +Minimal C repro skeleton (adapt per issue; `harness.c` is the full reference): + +```c +/* cc repro.c -llexbor */ +#include +#include +#include + +static lxb_status_t cb(lxb_dom_node_t *n, lxb_css_selector_specificity_t s, void *ctx) { + (*(int *)ctx)++; + return LXB_STATUS_OK; +} + +int main(void) { + const lxb_char_t html[] = ""; + const lxb_char_t sel[] = "[x~=\"\"]"; + int hits = 0; + + lxb_html_document_t *doc = lxb_html_document_create(); + lxb_html_document_parse(doc, html, sizeof(html) - 1); + + lxb_css_parser_t *parser = lxb_css_parser_create(); + lxb_css_parser_init(parser, NULL); + lxb_selectors_t *selectors = lxb_selectors_create(); + lxb_selectors_init(selectors); + + lxb_css_selector_list_t *list = + lxb_css_selectors_parse(parser, sel, sizeof(sel) - 1); + if (list == NULL) { printf("selector parse error\n"); return 1; } + + lxb_selectors_find(selectors, lxb_dom_interface_node(doc), + list, cb, &hits); + printf("matches: %d\n", hits); /* spec: 0 */ + return 0; +} +``` + +--- + +## Issue 1 — `[x~=""]` matches whitespace-only attribute values + +Per Selectors Level 4, `[att~=val]` with an empty `val` never matches: + +> If "val" is the empty string, it will never represent anything. +> — https://www.w3.org/TR/selectors-4/#attribute-representation (§6.1) + +lexbor instead matches elements whose attribute value consists only of +whitespace, suggesting its list-splitting yields an empty token for +whitespace-only values. Verified at v3.0.0 (`data-fid="a"` on the element): + +| document | selector | lexbor | spec / Chrome 149 | +|---------------------------|-----------|-----------|-------------------| +| `` (space) | `[x~=""]` | matches ❌ | no match | +| `` (tab) | `[x~=""]` | matches ❌ | no match | +| `` | `[x~=""]` | no match ✅ | no match | +| `` | `[x~=""]` | no match ✅ | no match | +| `` (control) | `[x~=a]` | matches ✅ | matches | + +Chrome 149 (`document.querySelectorAll`) returns no match for all `[x~=""]` +rows (verified 2026-06-10 via Playwright during the WordPress fix review). + +## Issue 2 — uppercase `I`/`S` attribute-selector modifiers rejected + +Selectors Level 4 §6.3 defines the modifiers explicitly as case-insensitive: + +> ...adding the identifier `i` (or `I`) ... adding the identifier `s` (or `S`) ... +> — https://www.w3.org/TR/selectors-4/#attribute-case + +lexbor parses the lowercase forms but reports a selector parse error for the +uppercase forms. Verified at v3.0.0: + +| selector | lexbor | spec | +|--------------|---------------|---------| +| `[x=abc i]` | parses ✅ | parses | +| `[x=abc I]` | parse error ❌ | parses | +| `[x=abc s]` | parses ✅ | parses | +| `[x=abc S]` | parse error ❌ | parses | + +Note for browser comparison: Chrome 149 had not shipped the `s` modifier at +all (throws SyntaxError), so compare `I` against Chrome and `S` against the +spec text / Firefox. + +## Issue 3 — non-ASCII ident code points below U+00F8 rejected + +CSS Syntax Level 3 defines the non-ASCII ident code points to include +U+00B7 and U+00C0–U+00D6 / U+00D8–U+00F6: + +> non-ASCII ident code point: U+00B7, U+00C0 to U+00D6, U+00D8 to U+00F6, +> U+00F8 to U+037D, ... +> — https://www.w3.org/TR/css-syntax-3/#non-ascii-ident-code-point + +lexbor's table appears to start at U+00F8: code points in the earlier ranges +are rejected both in ident-start and non-start positions. Verified at v3.0.0 +(raw UTF-8 selectors; class attribute contains the same characters): + +| selector | codepoint(s) | lexbor | spec | +|-----------|---------------------|---------------|---------| +| `.über` | U+00FC (≥ U+00F8) | parses ✅ | parses | +| `.øx` | U+00F8 (boundary) | parses ✅ | parses | +| `.Über` | U+00DC (U+00D8–F6) | parse error ❌ | parses | +| `.a·b` | U+00B7 (non-start) | parse error ❌ | parses | +| `.÷x` | U+00F7 (excluded) | parse error ✅ | error | + +The U+00F7 row is a control: the division sign is correctly NOT an ident code +point, so lexbor's boundary is off by exactly the U+00B7 / U+00C0–U+00F6 +ranges. Workaround used by this fuzzer: hex-escape all non-ASCII (`\dc ber` +parses fine), which is why this surfaces only with raw multibyte selectors. From 28df783d2107ad6f70ab303485f65d4d96d1e771 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 22:38:50 +0200 Subject: [PATCH 174/336] CSS selector fuzz: cover backslash-at-EOF escapes; record decisions Track the core EOF-escape fix (candidate finding 4, now confirmed against lexbor and fixed): - The invalid bucket's lone '\' entry is now a valid selector (type U+FFFD); replace it with "\\n" (backslash before a newline is not a valid escape and stays invalid). - New 'eof-escape' kind in the edge-escape bucket generates '.name\', '#name\', and 'name\' (including empty-name variants) with expected ASTs ending in U+FFFD, exercising both the EOF escape decode and the normalize-input trailing-whitespace handling. NEXT-STEPS.md: mark candidate finding 4 fixed (including the trailing-trim wrong-match-set bug its review surfaced) and record the session decisions: EOF-truncated attribute selectors will be made spec-conformant (auto-close), the HTML case-insensitive attribute value list will be implemented, grammar-level truncations stay invalid, no Trac tickets. Self-check OK; 5000-seed runs clean at seeds 1-5000 and 7000000+ (reviewer-chosen fresh range) with lexbor comparisons active. --- tools/css-selector-fuzz/NEXT-STEPS.md | 31 +++++++--- .../lib/SelectorGenerator.php | 59 +++++++++++++++++-- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 51a68dc06a5cf..af2ba87eca8cd 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -24,14 +24,29 @@ > worth doing on its own (it makes self-check robust to *any* future generator > change) and is the prerequisite for randomized class-NUL document injection. > -> **Candidate finding 4 (unverified, found in fix review):** per CSS Syntax 3 -> §4.3.8, `\` followed by EOF is a valid escape (EOF is not a newline), and -> §4.3.7 says consuming it returns U+FFFD — so `.foo\` should parse as class -> `foo\u{FFFD}`. WP's `next_two_are_valid_escape()` requires a code point after -> the backslash, so `.foo\` is rejected (`from_selectors()` → null). The -> string-context behavior (`'foo\` → `foo`, "do nothing" at EOF) is already -> spec-correct; only ident context diverges. Low severity (fail-safe null, not -> a mis-match); verify against browsers, then fix or document as intentional. +> **Candidate finding 4 — FIXED:** per CSS Syntax 3 §4.3.8, `\` followed by +> EOF is a valid escape (EOF is not a newline), and §4.3.7 says consuming it +> returns U+FFFD — so `.foo\` parses as class `foo\u{FFFD}`. Verified against +> lexbor (agrees: `.foo\` matches class `foo\u{FFFD}`; `\` parses as type +> `\u{FFFD}`). Fixed on this branch (`CSS selector:` commit): EOF guard in +> `consume_escaped_codepoint()` returns U+FFFD, `next_two_are_valid_escape()` +> accepts a backslash as the final byte. Review of the fix surfaced a second +> bug in the same family: `normalize_selector_input()` trimmed *trailing* +> whitespace before tokenizing, so `.foo\ ` (escaped space — valid class +> `foo `, matches nothing) and `.foo\` (invalid escape — must be +> rejected) both collapsed to `.foo\` and matched class `foo\u{FFFD}` — a +> wrong-match-set bug. Fixed by switching to `ltrim()`; the grammar consumes +> insignificant trailing whitespace. Fuzzer updated to match: the lone `\` +> invalid-bucket entry became `\` (still invalid), and `edge-escape` +> gained an `eof-escape` kind covering `.name\` / `#name\` / `name\`. +> +> **Session decisions (2026-06-10):** EOF-truncated selectors (`div[a=b`) +> will be made spec-conformant — CSS Syntax auto-closes open blocks at EOF — +> rather than documented as an intentional rejection. HTML's default +> case-insensitive attribute value list will be implemented (no-modifier + +> html-namespace + listed attribute; explicit `s` keeps forcing +> case-sensitivity). Grammar-level truncations (`[`, `[a=`, `div >`, `div,`) +> stay invalid — browsers reject those too. No Trac tickets for any of this. Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 88a2cbaefd9a9..54aa761781c5b 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -545,12 +545,61 @@ private function pick_name( string $pool_key ): string { private function gen_edge_escape(): array { $kind = $this->prng->weighted( array( - 'fffd-ident' => 50, - 'nul-input' => 25, - 'ws-input' => 25, + 'fffd-ident' => 40, + 'eof-escape' => 20, + 'nul-input' => 20, + 'ws-input' => 20, ) ); + if ( 'eof-escape' === $kind ) { + /* + * A backslash at the end of input is a valid escape ( EOF is not + * a newline ) and decodes to U+FFFD, in ident context only: + * `.foo\` is the class `foo\u{FFFD}`. + * + * https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point + */ + $name = $this->prng->chance( 30 ) ? '' : 'a' . $this->prng->int( 0, 99 ); + list( $selector, $self ) = $this->prng->choice( + array( + array( + '.' . $name . '\\', + array( + 'type' => null, + 'subs' => array( array( 'kind' => 'class', 'name' => $name . "\u{FFFD}" ) ), + ), + ), + array( + '#' . $name . '\\', + array( + 'type' => null, + 'subs' => array( array( 'kind' => 'id', 'name' => $name . "\u{FFFD}" ) ), + ), + ), + array( + $name . '\\', + array( + 'type' => $name . "\u{FFFD}", + 'subs' => null, + ), + ), + ) + ); + return array( + 'bucket' => 'edge-escape', + 'selector' => $selector, + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => array( + array( + 'context' => array(), + 'self' => $self, + ), + ), + ); + } + if ( 'fffd-ident' === $kind ) { // A class selector whose name is a single U+FFFD, produced by a // hex escape for an out-of-range codepoint. @@ -1307,7 +1356,9 @@ private function gen_invalid(): string { 'a >> b', '>', '-', - '\\', + // A lone '\' is a valid escape at EOF ( type selector U+FFFD ); + // '\' before a newline is not a valid escape. + "\\\n", "a\\\nb", 'a/**/b', '/* comment */ a', From c6916ce008756ab273c5738912d1dda6c094dad4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 23:02:33 +0200 Subject: [PATCH 175/336] CSS selector fuzz: cover EOF-truncated attribute selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the core EOF auto-close change: - New 'eof-truncated' kind in the edge-escape bucket: render an attribute compound, strip the trailing ']', sometimes also drop a closing string quote (EOF terminates the string, then the block) and sometimes append a trailing backslash to the unterminated string (the 'do nothing' escape arm, keeping that branch exercised after the '[a="x\' invalid template became valid). - Invalid bucket reshuffled along the new validity boundary: entries that EOF auto-close makes valid ('[a', '[ a', '[a=b', '[a="x\', '[a="b]', "[a='b]", '[a=b i') are replaced with still-invalid grammar-level truncations ('[a=', '[a= ', '[a~', '[a^', '[a=b x', '[a=b ix', '[a=b i x', '[5=b', '[a="bc'). COVERAGE.md regenerated: 384/408 lines (94.1%), effective 396/408 (97.1%). The parse_string() and ident-start EOF guards flipped from 'defensive, unreachable' to genuinely covered ('[a=' now reaches the value parsers at EOF); next_two_are_valid_escape()'s EOF guard is the remaining defensive line. NEXT-STEPS.md: record candidate finding 5 (escaped attribute-selector modifier idents like '[a=b \69]' are rejected by the byte-wise modifier switch; Chromium itself is inconsistent — accepts \69/i, rejects \73/s; fail-safe refusal, not fixed). Review panel: two approvals; 20k-case generator oracle loop and a fresh 9M-seed-range fuzz run by reviewers found no oracle mismatches. Gates: self-check OK, full suite 1631 green, 5000-seed run clean. --- tools/css-selector-fuzz/COVERAGE.md | 46 ++++++---- tools/css-selector-fuzz/NEXT-STEPS.md | 9 ++ .../lib/SelectorGenerator.php | 84 ++++++++++++++++--- 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/tools/css-selector-fuzz/COVERAGE.md b/tools/css-selector-fuzz/COVERAGE.md index 5c772ce27793c..5279d0363201e 100644 --- a/tools/css-selector-fuzz/COVERAGE.md +++ b/tools/css-selector-fuzz/COVERAGE.md @@ -7,21 +7,25 @@ with phpdbg's opcode log over 3000 deterministic seeds: | file | covered / executable | % | |---|---|---| -| class-wp-css-attribute-selector.php | 102 / 112 | 91.1% | +| class-wp-css-attribute-selector.php | 106 / 116 | 91.4% | | class-wp-css-class-selector.php | 10 / 10 | 100% | | class-wp-css-complex-selector-list.php | 16 / 16 | 100% | | class-wp-css-complex-selector.php | 59 / 66 | 89.4% | | class-wp-css-compound-selector-list.php | 27 / 28 | 96.4% | | class-wp-css-compound-selector.php | 29 / 32 | 90.6% | | class-wp-css-id-selector.php | 12 / 12 | 100% | -| class-wp-css-selector-parser-matcher.php | 106 / 108 | 98.1% | +| class-wp-css-selector-parser-matcher.php | 110 / 111 | 99.1% | | class-wp-css-type-selector.php | 15 / 17 | 88.2% | -| **TOTAL** | **376 / 401** | **93.8%** | +| **TOTAL** | **384 / 408** | **94.1%** | -The 25 unreached lines are all accounted for below. Twelve are a phpdbg -measurement artifact (the code executes); the other thirteen are defensive +The 24 unreached lines are all accounted for below. Twelve are a phpdbg +measurement artifact (the code executes); the other twelve are defensive guards that the public entry points cannot reach. Effective coverage of -reachable code is **388 / 401 = 96.8%**. +reachable code is **396 / 408 = 97.1%**. + +(Executable-line totals grew from 401 to 408 with the EOF-escape and +EOF-auto-close changes; two parser-matcher EOF guards that used to be +unreachable defensive lines are now genuinely exercised — see below.) ## phpdbg `case`-label artifact (12 lines — code executes) @@ -32,17 +36,17 @@ lexbor differential + self-check confirm the corresponding behavior). These are not real gaps: - `class-wp-css-attribute-selector.php` - - 287, 291, 295, 299, 303 — the `~= |= ^= $= *=` matcher operators. - - 330, 331, 336, 337 — the `i`/`I`/`s`/`S` case modifiers. + - 309, 313, 317, 321, 325 — the `~= |= ^= $= *=` matcher operators. + - 350, 351, 356, 357 — the `i`/`I`/`s`/`S` case modifiers. - `class-wp-css-compound-selector.php` - 120, 122, 124 — the `.` / `#` / `[` subclass-selector dispatch. -## Defensive guards unreachable from the public API (13 lines) +## Defensive guards unreachable from the public API (12 lines) These are internal precondition checks that the calling code already guarantees, or branches for grammar the parser never emits: -- `class-wp-css-attribute-selector.php:257` — `return null` when the first +- `class-wp-css-attribute-selector.php:282` — `return null` when the first byte is not `[`. `parse()` is only ever called by `parse_subclass_selector()` *after* it has matched `[`, so the guard never fires. @@ -54,23 +58,31 @@ guarantees, or branches for grammar the parser never emits: processor is not on a `#tag` token. `select()` only invokes matching while positioned on a tag; reachable only by calling `matches()` directly off a non-tag token. -- `class-wp-css-selector-parser-matcher.php:130` — `parse_string()` EOF - guard; every caller checks bounds and the opening quote before calling. -- `class-wp-css-selector-parser-matcher.php:429` — - `check_if_three_code_points_would_start_an_ident_sequence()` EOF guard; - callers bound-check first. +- `class-wp-css-selector-parser-matcher.php:351` — + `next_two_are_valid_escape()` EOF guard; every caller either bound-checks + first or only calls it on a known backslash byte. - `class-wp-css-type-selector.php:45` — `return false` when `get_tag()` is null during matching; matching only runs on resolved element tokens. - `class-wp-css-type-selector.php:75` — `parse()` EOF guard; the compound parser checks `offset < strlen` before calling. +Two guards documented here in earlier revisions are now genuinely covered: +the `parse_string()` EOF guard and the +`check_if_three_code_points_would_start_an_ident_sequence()` EOF guard are +both reached since EOF auto-close lets `[a=` call the value parsers at the +end of input. + ## Notes on what raised coverage - The `edge-escape` bucket drives the U+FFFD escape-decoder branch (`consume_escaped_codepoint` for NUL / surrogate / over-max codepoints) and the `normalize_selector_input` NUL→U+FFFD and CR/CRLF/FF→LF paths, - which the structural generators cannot reach. -- A few `invalid`-bucket templates (`[ a`, `[a="x\`, `a.`) were added to reach + which the structural generators cannot reach. Its `eof-escape` kind covers + the backslash-at-end-of-input → U+FFFD decode, and its `eof-truncated` + kind covers the EOF auto-close paths in the attribute parser, including + unterminated strings (with and without a trailing "do nothing" backslash, + which keeps the `parse_string` backslash-at-EOF arm exercised). +- A few `invalid`-bucket templates (`[a=`, `[a~`, `[a="bc`, `a.`) reach attribute / string / class parse guards that random structural generation rarely lands on. With them the per-file numbers above are **deterministic** at the documented 3000-seed window (e.g. `class-wp-css-class-selector.php` diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index af2ba87eca8cd..4bcf41dc07c78 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -40,6 +40,15 @@ > invalid-bucket entry became `\` (still invalid), and `edge-escape` > gained an `eof-escape` kind covering `.name\` / `#name\` / `name\`. > +> **Candidate finding 5 (recorded 2026-06-10, low severity, not fixed):** +> the attribute-selector case modifier is matched byte-wise (`i`/`I`/`s`/`S` +> literals), so an *escaped* modifier ident like `[a=b \69]` (tokenizes to +> the ident `i`) is rejected. Per the Selectors-4 grammar ` = +> i | s` these are ident tokens, so escapes should arguably be accepted — +> but browsers are themselves inconsistent (Chromium accepts `[a=b \69]` +> and rejects `[a=b \73]`). Fail-safe refusal, not a mis-match; revisit only +> if the matcher ever moves to token-level parsing. +> > **Session decisions (2026-06-10):** EOF-truncated selectors (`div[a=b`) > will be made spec-conformant — CSS Syntax auto-closes open blocks at EOF — > rather than documented as an intentional rejection. HTML's default diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 54aa761781c5b..82fe2b5745b0b 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -545,13 +545,69 @@ private function pick_name( string $pool_key ): string { private function gen_edge_escape(): array { $kind = $this->prng->weighted( array( - 'fffd-ident' => 40, - 'eof-escape' => 20, - 'nul-input' => 20, - 'ws-input' => 20, + 'fffd-ident' => 35, + 'eof-escape' => 20, + 'eof-truncated' => 15, + 'nul-input' => 15, + 'ws-input' => 15, ) ); + if ( 'eof-truncated' === $kind ) { + /* + * The end of input auto-closes an unterminated attribute selector + * block ( and an unterminated string inside it ): `[a=b` is the + * same selector as `[a=b]`. + * + * https://www.w3.org/TR/css-syntax-3/#consume-simple-block + */ + $matcher = $this->prng->choice( array( null, 'exact', 'one-of', 'exact-or-hyphen-suffixed', 'prefixed', 'suffixed', 'contains' ) ); + $value = null === $matcher ? null : $this->prng->choice( array( 'v' . $this->prng->int( 0, 99 ), 'a b', '', 'x,y', "caf\u{E9}" ) ); + $modifier = null !== $matcher && $this->prng->chance( 30 ) + ? $this->prng->choice( array( 'case-insensitive', 'case-sensitive' ) ) + : null; + $compound = array( + 'type' => $this->prng->chance( 50 ) ? 'div' : null, + 'subs' => array( + array( + 'kind' => 'attr', + 'name' => 'a' . $this->prng->int( 0, 99 ), + 'matcher' => $matcher, + 'value' => $value, + 'modifier' => $modifier, + ), + ), + ); + + // The attribute selector is the final rendered unit, so the render always ends with ']'. + $rendered = $this->render_compound( $compound ); + $truncated = substr( $rendered, 0, -1 ); + + // Sometimes also drop a closing string quote: EOF terminates the string, then closes the block. + $last_byte = substr( $truncated, -1 ); + if ( ( '"' === $last_byte || "'" === $last_byte ) && $this->prng->chance( 50 ) ) { + $truncated = substr( $truncated, 0, -1 ); + + // A backslash at the end of an unterminated string "does nothing": the value is unchanged. + if ( $this->prng->chance( 40 ) ) { + $truncated .= '\\'; + } + } + + return array( + 'bucket' => 'edge-escape', + 'selector' => $truncated, + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => array( + array( + 'context' => array(), + 'self' => $compound, + ), + ), + ); + } + if ( 'eof-escape' === $kind ) { /* * A backslash at the end of input is a valid escape ( EOF is not @@ -1333,23 +1389,29 @@ private function gen_invalid(): string { '. x', '..a', '.#a', - '[a', - '[ a', + /* + * EOF auto-closes an open attribute selector block + * ( '[a', '[a=b', '[a="b]', '[a=b i' are valid ), but + * grammar-level truncation is still invalid. + */ '[a=', + '[a= ', + '[a~', + '[a^', '[a=]', - '[a="x\\', '[=b]', '[a==b]', '[a~b]', '[a!=b]', - '[a=b', - '[a="b]', - "[a='b]", "[a=\"b\nc\"]", + "[a=\"b\nc", '[a=b x]', + '[a=b x', '[a=b ix]', - '[a=b i', + '[a=b ix', + '[a=b i x', '[5=b]', + '[5=b', 'a >', '> a', 'a > > b', From a2c21ae9cc746778672afca89ff8aee3aec7a6d7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 10 Jun 2026 23:54:44 +0200 Subject: [PATCH 176/336] CSS selector fuzz: mirror the HTML case-insensitive attribute list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the core change in the oracle stack: - ReferenceMatcher: independent copy of the 46-name list (the oracle must not share a possible misreading with the implementation under test); attr matching folds when no modifier + html-namespace row + listed name. A new $html_attr_ci flag threads through the matching entry points so the lexbor comparison can model an engine without the rule. - TreeCapture: html-processor rows now carry the element's namespace (svg/math subtrees and foreignObject integration points get correct per-element folding; rows without the field default to html, which matches both the model generator's html-only output and the namespace-blind standalone Tag Processor). - Worker: lexbor does not implement the rule ([rel=NOFOLLOW] fails to match rel="nofollow"); its expectation is now always recomputed with the list disabled, composed with the existing issue-368 quirks fold. Candidate upstream report recorded in NEXT-STEPS.md. - SelectorGenerator: case-flip twists in gen_attr_selector and, more importantly, path_attr_feature — the path-directed bucket pairs attribute name and value from the same real element, so a flipped operand makes the folding rule load-bearing for the mustMatchFid invariant. Mutation-tested: with the core folding branch disabled, a 3000-seed run fires 11 match-mismatch failures (review found the earlier pool-based flip alone was load-bearing in ~1/14k cases). - util: ascii_strtoupper and str_shuffle_case (ASCII-only, multibyte bytes pass through untouched). Review panel: two approvals (spec reviewer machine-diffed both list constants against the live spec; oracle reviewer verified row namespaces against match-time get_namespace() including integration points, the lexbor compensation composition, and util determinism). Gates: self-check OK, suite 1640 green, 5000-seed run clean, fresh 11M-seed-range reviewer run clean. --- tools/css-selector-fuzz/NEXT-STEPS.md | 18 ++++ .../lib/ReferenceMatcher.php | 97 ++++++++++++++++--- .../lib/SelectorGenerator.php | 38 +++++++- tools/css-selector-fuzz/lib/TreeCapture.php | 1 + tools/css-selector-fuzz/lib/Worker.php | 25 +++-- tools/css-selector-fuzz/lib/util.php | 17 ++++ 6 files changed, 170 insertions(+), 26 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 4bcf41dc07c78..7cd82c01cce89 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -49,6 +49,24 @@ > and rejects `[a=b \73]`). Fail-safe refusal, not a mis-match; revisit only > if the matcher ever moves to token-level parsing. > +> **HTML case-insensitive attribute value list — IMPLEMENTED (2026-06-10):** +> per https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors +> the values of ~46 listed attributes (`type`, `rel`, `lang`, `dir`, +> `media`, ...) match ASCII case-insensitively on HTML elements when the +> selector has no modifier; an explicit `s` still forces sensitivity, and +> elements outside the html namespace are unaffected. Oracle notes from +> verification: +> - **lexbor does not implement the rule at all** (`[rel=nofollow]` does +> not match `rel="NOFOLLOW"`) — compensated in the differential the same +> way as lexbor #368 (lexbor is compared against the reference run with +> the list disabled); candidate upstream report. +> - **Chromium applies the list to foreign elements too** (`[type=TEXT]` +> matches ``), diverging from the HTML spec's "on an +> HTML element" scoping. WP follows the spec (html namespace only, via +> `get_namespace()`). The standalone Tag Processor has no namespace +> tracking and applies the list to every element — an inherent +> tag-processor approximation, same as its ancestor-blind matching. +> > **Session decisions (2026-06-10):** EOF-truncated selectors (`div[a=b`) > will be made spec-conformant — CSS Syntax auto-closes open blocks at EOF — > rather than documented as an intentional rejection. HTML's default diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php index 5acbf9f5d4927..e86aa4e2e990b 100644 --- a/tools/css-selector-fuzz/lib/ReferenceMatcher.php +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -18,7 +18,9 @@ * - Class and ID matching is exact, except in quirks mode where it is * ASCII case-insensitive. * - Attribute value matching is exact (byte-wise) unless the `i` modifier - * requests ASCII case-insensitivity. + * requests ASCII case-insensitivity, or the attribute is in HTML's + * case-insensitive list, the selector has no modifier, and the element + * is in the html namespace (rows without a namespace field are html). * - For `^=`, `$=`, `*=` and `~=`, an empty (or for `~=`, whitespace- * containing) value matches nothing. * @@ -28,18 +30,78 @@ class ReferenceMatcher { const WHITESPACE = " \t\r\n\f"; + /** + * HTML's case-insensitive attribute value list: with no `i`/`s` + * modifier, these attributes' values match ASCII case-insensitively on + * HTML elements. Independent copy — the matcher must not share a + * possible misreading with the implementation under test. + * + * https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors + */ + const HTML_CASE_INSENSITIVE_ATTRIBUTES = array( + 'accept' => true, + 'accept-charset' => true, + 'align' => true, + 'alink' => true, + 'axis' => true, + 'bgcolor' => true, + 'charset' => true, + 'checked' => true, + 'clear' => true, + 'codetype' => true, + 'color' => true, + 'compact' => true, + 'declare' => true, + 'defer' => true, + 'dir' => true, + 'direction' => true, + 'disabled' => true, + 'enctype' => true, + 'face' => true, + 'frame' => true, + 'hreflang' => true, + 'http-equiv' => true, + 'lang' => true, + 'language' => true, + 'link' => true, + 'media' => true, + 'method' => true, + 'multiple' => true, + 'nohref' => true, + 'noresize' => true, + 'noshade' => true, + 'nowrap' => true, + 'readonly' => true, + 'rel' => true, + 'rev' => true, + 'rules' => true, + 'scope' => true, + 'scrolling' => true, + 'selected' => true, + 'shape' => true, + 'target' => true, + 'text' => true, + 'type' => true, + 'valign' => true, + 'valuetype' => true, + 'vlink' => true, + ); + /** * Expected match list for WP_HTML_Processor::select(). * - * @param array $list_ast Canonical complex selector list AST. - * @param array $rows Element rows in visit order, with ancestorTags. - * @param bool $quirks Whether the document parses in quirks mode. + * @param array $list_ast Canonical complex selector list AST. + * @param array $rows Element rows in visit order, with ancestorTags. + * @param bool $quirks Whether the document parses in quirks mode. + * @param bool $html_attr_ci Whether HTML's case-insensitive attribute value + * list applies. True models WP/browsers; false + * models an engine without the rule ( lexbor ). * @return string[] data-fid values in visit order. */ - public static function expected_html_matches_rows( array $list_ast, array $rows, bool $quirks ): array { + public static function expected_html_matches_rows( array $list_ast, array $rows, bool $quirks, bool $html_attr_ci = true ): array { $out = array(); foreach ( $rows as $row ) { - if ( self::list_matches_row( $list_ast, $row, $quirks ) ) { + if ( self::list_matches_row( $list_ast, $row, $quirks, $html_attr_ci ) ) { $out[] = $row['fid']; } } @@ -60,7 +122,7 @@ public static function expected_tag_matches_rows( array $list_ast, array $rows ) foreach ( $rows as $row ) { $matched = false; foreach ( $list_ast as $complex ) { - if ( self::compound_matches( $complex['self'], $row, false ) ) { + if ( self::compound_matches( $complex['self'], $row, false, true ) ) { $matched = true; break; } @@ -82,10 +144,10 @@ public static function expected_tag_processor_matches( array $list_ast, array $m return self::expected_tag_matches_rows( $list_ast, DocumentGenerator::rows_from_model( $model ) ); } - public static function list_matches_row( array $list_ast, array $row, bool $quirks ): bool { + public static function list_matches_row( array $list_ast, array $row, bool $quirks, bool $html_attr_ci = true ): bool { foreach ( $list_ast as $complex ) { if ( - self::compound_matches( $complex['self'], $row, $quirks ) && + self::compound_matches( $complex['self'], $row, $quirks, $html_attr_ci ) && self::explore_context( $complex['context'], $row['ancestorTags'] ) ) { return true; @@ -127,12 +189,12 @@ private static function explore_context( array $context, array $ancestor_tags ): return false; } - public static function compound_matches( array $compound, array $row, bool $quirks ): bool { + public static function compound_matches( array $compound, array $row, bool $quirks, bool $html_attr_ci = true ): bool { if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $row['tag'] ) ) { return false; } foreach ( (array) $compound['subs'] as $sub ) { - if ( ! self::sub_matches( $sub, $row, $quirks ) ) { + if ( ! self::sub_matches( $sub, $row, $quirks, $html_attr_ci ) ) { return false; } } @@ -143,14 +205,14 @@ private static function type_matches( string $type, string $tag ): bool { return '*' === $type || ascii_strtolower( $type ) === ascii_strtolower( $tag ); } - private static function sub_matches( array $sub, array $row, bool $quirks ): bool { + private static function sub_matches( array $sub, array $row, bool $quirks, bool $html_attr_ci ): bool { switch ( $sub['kind'] ) { case 'class': return self::class_matches( $sub['name'], $row, $quirks ); case 'id': return self::id_matches( $sub['name'], $row, $quirks ); case 'attr': - return self::attr_matches( $sub, $row ); + return self::attr_matches( $sub, $row, $html_attr_ci ); } return false; } @@ -201,7 +263,7 @@ private static function id_matches( string $wanted, array $row, bool $quirks ): : $id === $wanted; } - private static function attr_matches( array $sub, array $row ): bool { + private static function attr_matches( array $sub, array $row, bool $html_attr_ci ): bool { $attr_value = DocumentGenerator::get_attribute_value( $row, $sub['name'] ); if ( null === $attr_value ) { return false; @@ -214,7 +276,12 @@ private static function attr_matches( array $sub, array $row ): bool { } $wanted = (string) $sub['value']; - $case_insensitive = 'case-insensitive' === $sub['modifier']; + $case_insensitive = 'case-insensitive' === $sub['modifier'] || ( + $html_attr_ci && + null === $sub['modifier'] && + 'html' === ( $row['namespace'] ?? 'html' ) && + isset( self::HTML_CASE_INSENSITIVE_ATTRIBUTES[ ascii_strtolower( $sub['name'] ) ] ) + ); if ( $case_insensitive ) { $attr_value = ascii_strtolower( $attr_value ); $wanted = ascii_strtolower( $wanted ); diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 82fe2b5745b0b..1f3a8fa2e89c2 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -423,11 +423,29 @@ private function gen_attr_selector(): array { ) ); + $value = $this->gen_attr_value(); + + /* + * HTML's case-insensitive attribute value list: with no modifier, + * the values of listed attributes ( type, rel, lang, dir, ... ) + * match ASCII case-insensitively on HTML elements. Sometimes flip + * the case of the selector value for a listed attribute so the + * differential exercises that rule rather than relying on sampled + * values happening to differ in case. + */ + if ( + '' === $modifier && + isset( ReferenceMatcher::HTML_CASE_INSENSITIVE_ATTRIBUTES[ ascii_strtolower( $name ) ] ) && + $this->prng->chance( 40 ) + ) { + $value = $this->prng->chance( 50 ) ? ascii_strtoupper( $value ) : str_shuffle_case( $value, $this->prng ); + } + return array( 'kind' => 'attr', 'name' => $name, 'matcher' => $matcher, - 'value' => $this->gen_attr_value(), + 'value' => $value, 'modifier' => '' === $modifier ? null : $modifier, ); } @@ -838,7 +856,7 @@ private function path_compound_for( array $element ): array { continue; } $seen_attrs[ $lower ] = true; - $features[] = $this->path_attr_feature( $lower, $attr[1] ); + $features[] = $this->path_attr_feature( $lower, $attr[1], 'html' === ( $element['namespace'] ?? 'html' ) ); } $subs = array(); @@ -864,7 +882,7 @@ private function path_compound_for( array $element ): array { } /** An attribute selector that the (name, value) pair satisfies. */ - private function path_attr_feature( string $name, $value ): array { + private function path_attr_feature( string $name, $value, bool $is_html_namespace = true ): array { $presence = array( 'kind' => 'attr', 'name' => $this->prng->chance( 15 ) ? $this->random_case( $name ) : $name, @@ -933,6 +951,20 @@ private function path_attr_feature( string $name, $value ): array { } else { $modifier = 'case-sensitive'; } + } elseif ( + $is_html_namespace && + isset( ReferenceMatcher::HTML_CASE_INSENSITIVE_ATTRIBUTES[ $name ] ) && + $this->prng->chance( 50 ) + ) { + /* + * HTML's case-insensitive attribute value list: with no modifier + * the flipped operand still satisfies the (name, value) pair on + * an html-namespace element, which makes the folding rule + * load-bearing for the mustMatchFid invariant — name and value + * here come from the same real element, unlike the independent + * pools in gen_attr_selector. + */ + $operand = $this->random_case( $operand ); } return array( diff --git a/tools/css-selector-fuzz/lib/TreeCapture.php b/tools/css-selector-fuzz/lib/TreeCapture.php index 616a3ff65ba76..2350db024c8ad 100644 --- a/tools/css-selector-fuzz/lib/TreeCapture.php +++ b/tools/css-selector-fuzz/lib/TreeCapture.php @@ -67,6 +67,7 @@ public static function capture( string $html, ?string $context = null ): array { 'fid' => self::fid_of( $processor ), 'attrs' => self::attrs_of( $processor ), 'ancestorTags' => array_reverse( $breadcrumbs ), + 'namespace' => $processor->get_namespace(), ); } diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 5bb607a032c58..ed3a9c7fa24d2 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -785,15 +785,24 @@ private static function check_lexbor_differential( array $complex_ast, string $s } /* - * lexbor #368: class/#id match ASCII case-insensitively even in - * no-quirks documents. Compare lexbor against the reference run - * with quirks-style class/ID folding ( the only thing the flag - * affects ) so the rest of the semantics still get differential - * coverage; WP itself is still held to the strict expectation. + * Two known lexbor deviations are compensated for so the rest of the + * semantics still get differential coverage; WP itself is still held + * to the strict expectation: + * + * - lexbor #368: class/#id match ASCII case-insensitively even in + * no-quirks documents. Compare lexbor against the reference run + * with quirks-style class/ID folding. + * - lexbor does not implement HTML's case-insensitive attribute + * value list ( [rel=NOFOLLOW] does not match rel="nofollow" ), + * where browsers and WP do. Compare lexbor against the reference + * run with that list disabled. */ - $expected_for_lexbor = LexborOracle::has_issue_368() - ? ReferenceMatcher::expected_html_matches_rows( $complex_ast, $rows, true ) - : $expected; + $expected_for_lexbor = ReferenceMatcher::expected_html_matches_rows( + $complex_ast, + $rows, + LexborOracle::has_issue_368() ? true : $quirks, + false + ); // lexbor reports in document order, WP/reference in visit order — // compare as multisets. diff --git a/tools/css-selector-fuzz/lib/util.php b/tools/css-selector-fuzz/lib/util.php index 6fe6662a6a3b8..d70d5cff9bae1 100644 --- a/tools/css-selector-fuzz/lib/util.php +++ b/tools/css-selector-fuzz/lib/util.php @@ -137,6 +137,23 @@ function ascii_strtolower( string $input ): string { return strtr( $input, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' ); } +function ascii_strtoupper( string $input ): string { + return strtr( $input, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ); +} + +/** Flips the case of each ASCII letter independently with 50% probability. */ +function str_shuffle_case( string $input, Prng $prng ): string { + $out = ''; + for ( $i = 0; $i < strlen( $input ); $i++ ) { + $byte = $input[ $i ]; + if ( $prng->chance( 50 ) ) { + $byte = ctype_lower( $byte ) ? ascii_strtoupper( $byte ) : ascii_strtolower( $byte ); + } + $out .= $byte; + } + return $out; +} + /** * Splits a valid UTF-8 string into codepoints. * From 892b0e9d5ff7cda72841f7746ea55f94e47e7bc2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 00:08:19 +0200 Subject: [PATCH 177/336] CSS selector fuzz: draft two more upstream lexbor reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues 4 and 5, both surfaced while verifying the WP conformance fixes against lexbor and both re-verified directly against the harness: 4. EOF does not auto-close an open attribute selector block: '[att=val' is a parse error where CSS Syntax §5.4.8 returns the block ('[att=val]') and Chrome accepts it. Controls confirm grammar-level truncation ('[att=', '[') is correctly rejected and escape-at-EOF ('.foo\' -> foo U+FFFD) already works — the gap is specifically the simple-block auto-close. 5. HTML's case-insensitive attribute value list is not implemented: '[rel=nofollow]' does not match rel="NOFOLLOW" where the HTML spec and Chrome fold the 46 listed attributes' values. Controls confirm explicit i/s modifiers and unlisted attributes behave. Includes the namespace-scoping caveat (spec scopes to HTML elements; Chrome folds SVG too). Both are compensated for in this fuzzer's differential (issue 4 never reaches lexbor because the differential compares canonical re-renders; issue 5 is compensated like #368 by comparing lexbor against the reference run with the list disabled). Same filing-agent protocol as issues 1-3: re-verify at master, dedupe, one self-contained C repro per issue. --- .../lexbor/UPSTREAM-ISSUES.md | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md index 8ebf17484b095..9ec23e414dcfa 100644 --- a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md +++ b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md @@ -1,9 +1,10 @@ # lexbor — draft upstream bug reports -Three spec-conformance bugs in liblexbor's CSS selectors support, found while +Five spec-conformance bugs in liblexbor's CSS selectors support, found while using lexbor as a differential oracle for the WordPress HTML-API CSS selector -fuzzer (`tools/css-selector-fuzz/`). All three were re-verified directly -against the harness on 2026-06-10. +fuzzer (`tools/css-selector-fuzz/`). Issues 1–3 were re-verified directly +against the harness on 2026-06-10; issues 4–5 surfaced during the WP +conformance-fix session and were re-verified on 2026-06-11. - **Pinned version:** lexbor v3.0.0 (`2ae88a1c6b52`), built by `tools/css-selector-fuzz/lexbor/build.sh`. @@ -20,7 +21,9 @@ against the harness on 2026-06-10. the report which commit you tested. 2. **Search for duplicates** before filing (suggested queries: `~=`, `attr-modifier`, `case insensitive modifier`, `ident code point`, - `U+00B7`, `non-ascii`). #368 shows the maintainer's preferred repro style. + `U+00B7`, `non-ascii`, `EOF`, `unclosed`, `simple block`, + `case-insensitive attribute`, `querySelector`). #368 shows the + maintainer's preferred repro style. 3. **One issue per bug.** Reduce each to a self-contained C repro (sketch below); maintainers should not need this repo's harness. 4. Reproduction via this repo (fast path): build the harness @@ -139,3 +142,78 @@ The U+00F7 row is a control: the division sign is correctly NOT an ident code point, so lexbor's boundary is off by exactly the U+00B7 / U+00C0–U+00F6 ranges. Workaround used by this fuzzer: hex-escape all non-ASCII (`\dc ber` parses fine), which is why this surfaces only with raw multibyte selectors. + +## Issue 4 — EOF does not auto-close an open attribute selector block + +Per CSS Syntax Level 3, tokenization auto-closes unterminated simple blocks +at the end of input (a parse error, but the block is returned), and an +unterminated string at EOF returns the string token: + +> \: This is a parse error. Return the block. +> — https://www.w3.org/TR/css-syntax-3/#consume-simple-block (§5.4.8) + +> EOF: This is a parse error. Return the \. +> — https://www.w3.org/TR/css-syntax-3/#consume-string-token (§4.3.5) + +So `[att=val` is the same selector as `[att=val]`, and `[att="a b` carries +the string value `a b`. lexbor reports a selector parse error for every +EOF-truncated attribute selector. Verified at v3.0.0 against +`
` / `
`: + +| selector | lexbor | spec / Chrome 149 | +|-----------------|---------------|-------------------| +| `[att]` | parses ✅ | parses | +| `[att=val]` | parses ✅ | parses | +| `[att` | parse error ❌ | parses, matches | +| `[att=val` | parse error ❌ | parses, matches | +| `[att="a b` | parse error ❌ | parses, matches | +| `[att=val i` | parse error ❌ | parses, matches | +| `div[att` | parse error ❌ | parses, matches | +| `[att=` | parse error ✅ | error (grammar) | +| `[att~` | parse error ✅ | error (grammar) | +| `[` | parse error ✅ | error (grammar) | +| `[att=val, div` | parse error ✅ | error (comma is inside the open block) | + +The last four rows are controls: truncation inside the selector *grammar* +(matcher without value, lone bracket) is invalid even after auto-close, and +lexbor correctly rejects those. Chrome 149 (`document.querySelectorAll`) +accepts and rejects exactly per the table (verified 2026-06-10 via +Playwright). Note lexbor's escape handling at EOF is fine — `.foo\` parses +as class `foo\u{FFFD}` per §4.3.7 — the gap is specifically the simple-block +auto-close. + +## Issue 5 — HTML's case-insensitive attribute value list not implemented + +HTML defines 46 attributes (`type`, `rel`, `lang`, `dir`, `media`, +`hreflang`, `http-equiv`, ...) whose values must match ASCII +case-insensitively in attribute selectors on an HTML element when the +selector has no `i`/`s` modifier: + +> Attribute selectors on an HTML element in an HTML document must treat the +> values of attributes with the following names as ASCII case-insensitive: … +> — https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors + +lexbor matches all attribute values case-sensitively unless the selector +carries an explicit `i`. Verified at v3.0.0 against +`` (`e1`), `` (`e2`), +`` (`e3`): + +| selector | lexbor | spec / Chrome 149 | +|--------------------|--------------|-------------------| +| `[rel=nofollow]` | `e2` only ❌ | `e1` and `e2` | +| `[rel=NOFOLLOW]` | `e1` only ❌ | `e1` and `e2` | +| `[rel=nofollow i]` | `e1`, `e2` ✅ | `e1` and `e2` | +| `[rel=nofollow s]` | `e2` only ✅ | `e2` only | +| `[data-x=abc]` | no match ✅ | no match (unlisted attribute) | + +The last three rows are controls: explicit modifiers work, and attributes +outside the list stay case-sensitive. Chrome 149 agrees with the spec column +(verified 2026-06-10 via Playwright), with one scoping caveat the report +should mention: the spec restricts the rule to elements in the HTML +namespace, but Chrome also folds on SVG-namespace elements +(`` matches `[type=TEXT]`), so an implementation true +to the spec letter would scope by element namespace. This may be framed as +a feature request rather than a bug if lexbor considers document-language +selector rules out of scope for its selectors module — but lexbor is an +HTML engine and browsers uniformly implement the folding, so matching +against HTML documents diverges from every browser without it. From 485af4b79ddd7660aab1667d96b0e02b96f16953 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 00:11:01 +0200 Subject: [PATCH 178/336] CSS selector fuzz: bring NEXT-STEPS status up to date - Add the missing IMPLEMENTED entry for EOF auto-close (the session decision block still said 'will be made spec-conformant'). - Record the invalid-UTF-8 escape-decode wart both review panels flagged ('\' + invalid byte decodes to mb_substitute_character, '?', instead of U+FFFD) and tie it to the open handoff item 5 contract decision. - Point the lexbor gaps at their now-drafted UPSTREAM-ISSUES.md entries (issues 4 and 5) instead of 'candidate upstream report'. - Note the mutation-test result for the path-directed case-flip and the two minor review leftovers (namespace-defaulting dead helpers, s-modifier differential coverage). - Fix the stale repo-state paragraph (the tooling has been committed on this branch since 2026-06-10) and list what remains open. --- tools/css-selector-fuzz/NEXT-STEPS.md | 61 ++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 7cd82c01cce89..d5551395382fe 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -49,6 +49,28 @@ > and rejects `[a=b \73]`). Fail-safe refusal, not a mis-match; revisit only > if the matcher ever moves to token-level parsing. > +> **EOF auto-close for attribute selectors — IMPLEMENTED (2026-06-10):** +> per CSS Syntax 3 §5.4.8/§4.3.5, the end of input closes an unterminated +> simple block (and an unterminated string), so `[att=val`, `[att`, +> `[att="a b`, and `[att=val i` are valid selectors; grammar-level +> truncations (`[`, `[a=`, `[a~`, `[a=b, div`) stay invalid. Verified +> against Chromium form-by-form, including an exhaustive per-byte +> truncation table in review. lexbor rejects all EOF-truncated forms +> (drafted as `lexbor/UPSTREAM-ISSUES.md` issue 4); the differential is +> unaffected because it compares canonical re-renders. Fuzzer gained an +> `eof-truncated` edge-escape kind and the invalid corpus was reshuffled +> along the new validity boundary; COVERAGE.md regenerated. +> +> **Escape decode of invalid UTF-8 bytes (recorded 2026-06-10, not fixed):** +> `\` followed by an invalid UTF-8 byte decodes through `mb_substr()`'s +> substitution character — `?` by default — instead of U+FFFD +> (`consume_escaped_codepoint()`, identity-escape arm). Pre-existing, +> byte-identical before/after the EOF fixes; flagged independently by both +> review panels. Belongs to the open invalid-UTF-8 input policy decision +> (handoff item 5): per spec, input preprocessing operates on decoded code +> points, so byte-level decode errors should arguably become U+FFFD before +> tokenization rather than leak `mb_substitute_character`. +> > **HTML case-insensitive attribute value list — IMPLEMENTED (2026-06-10):** > per https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors > the values of ~46 listed attributes (`type`, `rel`, `lang`, `dir`, @@ -59,7 +81,16 @@ > - **lexbor does not implement the rule at all** (`[rel=nofollow]` does > not match `rel="NOFOLLOW"`) — compensated in the differential the same > way as lexbor #368 (lexbor is compared against the reference run with -> the list disabled); candidate upstream report. +> the list disabled); drafted as `lexbor/UPSTREAM-ISSUES.md` issue 5. +> - The case-flip generator twist in `path_attr_feature` makes the folding +> load-bearing for `mustMatchFid` (mutation-tested: disabling the core +> branch fires 11 failures in 3000 seeds). Minor leftovers from review: +> the unused `expected_*_processor_matches` back-compat helpers in +> `ReferenceMatcher` silently default rows to the html namespace — fine +> today (the safe model generator emits no foreign content) but a trap +> for a future caller; and `s`-forces-sensitivity only gets differential +> coverage when sampled values happen to differ in case (pinned by unit +> tests instead). > - **Chromium applies the list to foreign elements too** (`[type=TEXT]` > matches ``), diverging from the HTML spec's "on an > HTML element" scoping. WP follows the spec (html namespace only, via @@ -67,18 +98,26 @@ > tracking and applies the list to every element — an inherent > tag-processor approximation, same as its ancestor-blind matching. > -> **Session decisions (2026-06-10):** EOF-truncated selectors (`div[a=b`) -> will be made spec-conformant — CSS Syntax auto-closes open blocks at EOF — -> rather than documented as an intentional rejection. HTML's default -> case-insensitive attribute value list will be implemented (no-modifier + -> html-namespace + listed attribute; explicit `s` keeps forcing -> case-sensitivity). Grammar-level truncations (`[`, `[a=`, `div >`, `div,`) -> stay invalid — browsers reject those too. No Trac tickets for any of this. +> **Session decisions (2026-06-10, both since implemented — see the +> IMPLEMENTED entries above):** EOF-truncated selectors (`div[a=b`) are +> spec-conformant (CSS Syntax auto-closes open blocks at EOF) rather than +> documented as an intentional rejection. HTML's default case-insensitive +> attribute value list is implemented (no-modifier + html-namespace + +> listed attribute; explicit `s` keeps forcing case-sensitivity). +> Grammar-level truncations (`[`, `[a=`, `div >`, `div,`) stay invalid — +> browsers reject those too. No Trac tickets for any of this. +> +> **Still open from the original follow-up list:** the O(1) identity-escape +> decode (perf only, do only if asked) and the invalid-UTF-8 input policy +> (contract decision; see the escape-decode note above), plus the tooling +> items in this file's hardening notes (self-check decoupling, class-NUL +> injection, vacuous-assertion rate, quirks-mode single-oracle gap). Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch -`html-css-fuzz` @ `6ebbcc2fe4` (trunk + merged `html-api/add-css-selector-parser`). -PHP 8.4.21. Everything under `tools/css-selector-fuzz/` is untracked; nothing -committed. `/artifacts` is gitignored (runner output lives there). +`html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`). +PHP 8.4.21. The fuzzer and all fixes are committed on this branch +(`CSS selector:` / `CSS selector fuzz:` prefixed commits). `/artifacts` is +gitignored (runner output lives there). ## Measured weaknesses driving this plan From 232a1240bf9f458e1fbd8f8464db3bfd3ae8cb6b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 10:48:31 +0200 Subject: [PATCH 179/336] CSS selector fuzz: record O(1) identity-escape decode as implemented Move the perf follow-up out of the still-open list and record the outcome: _wp_scan_utf8-based in-place sizing, byte-identical behavior (74M differential cases), linear scaling, and the deliberately-kept quadratic mb_substr fallback for escaped invalid bytes pending the invalid-UTF-8 policy decision. --- tools/css-selector-fuzz/NEXT-STEPS.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index d5551395382fe..b4c38885ffa07 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -107,11 +107,28 @@ > Grammar-level truncations (`[`, `[a=`, `div >`, `div,`) stay invalid — > browsers reject those too. No Trac tickets for any of this. > -> **Still open from the original follow-up list:** the O(1) identity-escape -> decode (perf only, do only if asked) and the invalid-UTF-8 input policy -> (contract decision; see the escape-decode note above), plus the tooling -> items in this file's hardening notes (self-check decoupling, class-NUL -> injection, vacuous-assertion rate, quirks-mode single-oracle gap). +> **O(1) identity-escape decode — IMPLEMENTED (2026-06-11, perf only):** +> `consume_escaped_codepoint()`'s identity arm no longer copies the input +> tail per escape (`mb_substr( substr( … ) )`); it sizes the code point in +> place with `_wp_scan_utf8( $input, $at, $invalid_length, 4, 1 )` +> (`compat-utf8.php`, WP 6.9). 200KB all-escape selector: 180 ms → 45 ms, +> scaling now linear (47/90/180 ms at 200/400/800KB; previously ~4× per +> doubling). Behavior is byte-identical by construction: escapes of +> *invalid* UTF-8 still fall through to the literal old `mb_substr()` line +> (re-verified ~74M differential cases, 0 mismatches, including non-default +> `mb_substitute_character` settings), so the open invalid-UTF-8 policy +> decision is untouched — and that fallback path remains quadratic for +> selectors made of escaped invalid bytes (accepted; developer-supplied +> input). Caution recorded in-code: `_wp_utf8_codepoint_span()` looks like +> the natural helper but passes `max_bytes = null`, making its ASCII +> fast-path O(tail) per call — quadratic again. Escape pin coverage grew to +> 14 cases (2/3/4-byte chars incl. at-EOF, NUL, each invalid-byte class). +> +> **Still open from the original follow-up list:** the invalid-UTF-8 input +> policy (contract decision; see the escape-decode note above), plus the +> tooling items in this file's hardening notes (self-check decoupling, +> class-NUL injection, vacuous-assertion rate, quirks-mode single-oracle +> gap). Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`). From 9c2cb64d5e338699ed30af567016927cb614b860 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 11:46:05 +0200 Subject: [PATCH 180/336] CSS selector fuzz: note the canary red suite for the UTF-8 policy item The invalid-UTF-8 escape-decode note now points at the mb_substitute_character canary fixture and pins landed in wpCssSelectorParserMatcher.php, which serve as the ready-made red suite for the item-5 U+FFFD fix. --- tools/css-selector-fuzz/NEXT-STEPS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index b4c38885ffa07..5b79201125cf1 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -69,7 +69,12 @@ > review panels. Belongs to the open invalid-UTF-8 input policy decision > (handoff item 5): per spec, input preprocessing operates on decoded code > points, so byte-level decode errors should arguably become U+FFFD before -> tokenization rather than leak `mb_substitute_character`. +> tokenization rather than leak `mb_substitute_character`. A red suite for +> the fix is in place (2026-06-11): `wpCssSelectorParserMatcher.php` pins +> `mb_substitute_character()` to a U+2603 canary in set_up()/tear_down(), +> and its seven invalid-byte escape pins (plus a dedicated offset-overrun +> test) assert the leak's damage — swallowed characters, offset past end +> of input. Decoding to U+FFFD per maximal subpart flips every one. > > **HTML case-insensitive attribute value list — IMPLEMENTED (2026-06-10):** > per https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors From ec99a9f893cd4498704675bfdcf8817794414609 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 18:08:11 +0200 Subject: [PATCH 181/336] CSS selector fuzz: Model the scrub notice in the worker invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core scrub change makes from_selectors() report _doing_it_wrong() once per parse of an invalid-UTF-8 selector. The worker's notice invariants assumed zero notices for parseable selectors and exactly two for unparseable ones; the chaos/mutated buckets organically produce invalid-UTF-8 selectors, so a 5000-seed run failed 108 cases against the new core behavior (94 doing-it-wrong-unexpected, 12 doing-it-wrong-missing, 2 case-determinism). Worker changes: - flush_select_parse_caches(): both select() implementations memoize the most recently parsed selector string in a function static, so whether a call re-parses — and therefore whether the parse-time scrub notice fires — depended on worker history, breaking the case-determinism re-run. Parsing a sentinel (#-fuzz-cache-flush-) through both processors before each notice-assertion window makes exactly one parse happen inside it. The flush works even for unparseable sentinels (the cache assigns before the null check) and precedes reset_doing_it_wrong(), so it cannot pollute recordings. - check_select_matches() expects exactly one scrub notice — named WP_CSS_Compound_Selector_List::from_selectors for the tag target, WP_CSS_Complex_Selector_List::from_selectors for html — iff wp_is_valid_utf8() rejects the selector string, and nothing else. Review verified the predicate is exactly equivalent to "the scrub changed the input" (exhaustive 1-2-byte strings plus 2M random). - check_select_rejection() expects the two per-call select() notices (those fire on cache hits too) plus one leading scrub notice for invalid-UTF-8 selectors, order- and name-exact via notices_match(). Stale comments updated now that parsed ASTs are valid UTF-8 by construction: Metamorph's variants() guard and the lexbor differential's skipped-utf8 state are kept as defense in depth (a nonzero skipped-utf8 tally now indicates a normalization bypass), and the invariant glossary describes the expected-set semantics. NEXT-STEPS.md: the invalid-UTF-8 policy item is resolved as scrub (decision history, the linked value-getter pin obligation, the optional parse()-visibility follow-up); the O(1) decode entry notes its mb_substr() fallback was since removed; the still-open list points at the deferred coverage work (dedicated invalid-UTF-8 generator bucket, raw-byte mutation class, explicit lexbor probe — handoff drafted) and records that the chaos/mutated buckets already exercise the scrub organically, with lexbor agreeing across clean 5000- and 10000-seed runs. Gates: self-check OK, 5000 seeds 0 failures, plus independent reviewer runs (2x2000 determinism-checked, 8000 additional seeds, all clean). --- tools/css-selector-fuzz/NEXT-STEPS.md | 61 ++++++++---- tools/css-selector-fuzz/lib/Metamorph.php | 10 +- tools/css-selector-fuzz/lib/Worker.php | 108 ++++++++++++++++++++-- 3 files changed, 146 insertions(+), 33 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 5b79201125cf1..b84ec6130f47a 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -61,20 +61,36 @@ > `eof-truncated` edge-escape kind and the invalid corpus was reshuffled > along the new validity boundary; COVERAGE.md regenerated. > -> **Escape decode of invalid UTF-8 bytes (recorded 2026-06-10, not fixed):** -> `\` followed by an invalid UTF-8 byte decodes through `mb_substr()`'s -> substitution character — `?` by default — instead of U+FFFD -> (`consume_escaped_codepoint()`, identity-escape arm). Pre-existing, -> byte-identical before/after the EOF fixes; flagged independently by both -> review panels. Belongs to the open invalid-UTF-8 input policy decision -> (handoff item 5): per spec, input preprocessing operates on decoded code -> points, so byte-level decode errors should arguably become U+FFFD before -> tokenization rather than leak `mb_substitute_character`. A red suite for -> the fix is in place (2026-06-11): `wpCssSelectorParserMatcher.php` pins -> `mb_substitute_character()` to a U+2603 canary in set_up()/tear_down(), -> and its seven invalid-byte escape pins (plus a dedicated offset-overrun -> test) assert the leak's damage — swallowed characters, offset past end -> of input. Decoding to U+FFFD per maximal subpart flips every one. +> **Invalid-UTF-8 input policy — IMPLEMENTED as scrub (2026-06-11):** +> selector strings are UTF-8 text; `normalize_selector_input()` now decodes +> the byte stream first via `wp_scrub_utf8()` (WP 6.9, maximal-subpart +> U+FFFD replacement, matching the WHATWG decoder CSS Syntax §3.2 invokes), +> and reports a `_doing_it_wrong()` (named `::from_selectors`) when +> the input changed. The `mb_substitute_character()` leak in +> `consume_escaped_codepoint()` is gone structurally: the identity arm's +> `mb_substr()` fallback is replaced by "consume the maximal subpart the +> `_wp_scan_utf8()` scan already reported, return one U+FFFD" — reachable +> only via direct `parse()` calls with un-normalized input, and consistent +> with the scrub when it is. Decision history: reject (`wp_is_valid_utf8()` +> → null) and raw passthrough were rejected after a three-persona +> adversarial panel; scrub is the unique option stable under both the +> current raw value getters and their likely scrubbed future. The U+2603 +> canary in `wpCssSelectorParserMatcher.php` set_up() is retained +> permanently — its job inverted from documenting the leak to proving +> setting-independence. Worker.php learned the notice contract (scrub +> notice expected iff `!wp_is_valid_utf8(selector)`) and flushes the +> `select()` parse caches before each notice-assertion window so the +> once-per-parse notice is deterministic under case re-runs. +> **Linked obligation:** the select-level pin +> `test_select_scrubbed_selector_does_not_match_raw_invalid_document_bytes` +> documents that scrubbed selectors cannot match raw invalid document +> bytes; if the HTML API value getters (`get_attribute()`, `class_list()`, +> …) are ever changed to scrub their return values, that case flips to a +> match and the pin must be updated in the same change. +> **Optional follow-up:** tightening the `parse()` prototype from public to +> protected (the classes are `@access private`) would make un-normalized +> input structurally impossible and let the defensive escape arm be +> deleted. > > **HTML case-insensitive attribute value list — IMPLEMENTED (2026-06-10):** > per https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors @@ -128,12 +144,19 @@ > the natural helper but passes `max_bytes = null`, making its ASCII > fast-path O(tail) per call — quadratic again. Escape pin coverage grew to > 14 cases (2/3/4-byte chars incl. at-EOF, NUL, each invalid-byte class). +> (Superseded the same day for invalid bytes: the `mb_substr()` fallback and +> its quadratic tail were removed by the scrub implementation — see the +> invalid-UTF-8 policy entry above.) > -> **Still open from the original follow-up list:** the invalid-UTF-8 input -> policy (contract decision; see the escape-decode note above), plus the -> tooling items in this file's hardening notes (self-check decoupling, -> class-NUL injection, vacuous-assertion rate, quirks-mode single-oracle -> gap). +> **Still open from the original follow-up list:** the tooling items in +> this file's hardening notes (self-check decoupling, class-NUL injection, +> vacuous-assertion rate, quirks-mode single-oracle gap), plus deferred +> fuzzer coverage for the scrub surface (dedicated invalid-UTF-8 generator +> bucket with maximal-subpart AST expectations, raw-byte mutation class, +> explicit lexbor invalid-byte probe — handoff drafted 2026-06-11; note +> the chaos/mutated buckets already produce invalid-UTF-8 selectors +> organically and lexbor agreed with the scrubbed results across a clean +> 5000-seed run). Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`). diff --git a/tools/css-selector-fuzz/lib/Metamorph.php b/tools/css-selector-fuzz/lib/Metamorph.php index 44e0e434ff4b2..159d8e8bedd52 100644 --- a/tools/css-selector-fuzz/lib/Metamorph.php +++ b/tools/css-selector-fuzz/lib/Metamorph.php @@ -35,11 +35,11 @@ class Metamorph { */ public static function variants( array $list_ast, Prng $prng ): array { /* - * The WP parser passes raw bytes through: a selector that is not - * valid UTF-8 yields AST names that are not valid UTF-8 (it does - * not substitute U+FFFD). The renderer can only round-trip valid - * UTF-8 names, so such ASTs (only reachable from chaos/mutated - * inputs) are not transformable. + * from_selectors() scrubs invalid UTF-8 to U+FFFD before parsing, so + * parsed AST names are always valid UTF-8 and this guard should be + * unreachable. It stays as defense in depth: the renderer can only + * round-trip valid UTF-8 names, and a future AST source that skips + * normalization would otherwise corrupt the variants silently. */ if ( ! ast_strings_are_utf8( $list_ast ) ) { return array(); diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index ed3a9c7fa24d2..86ad865d50e37 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -22,9 +22,15 @@ * from the reference matcher. * - match-mismatch-tag: WP_HTML_Tag_Processor::select() match set * differs from the reference matcher. - * - doing-it-wrong-unexpected: _doing_it_wrong fired for a selector that parsed. - * - doing-it-wrong-missing: _doing_it_wrong did not fire ( or fired the wrong - * number of times ) for an unparseable selector. + * - doing-it-wrong-unexpected: the _doing_it_wrong calls during matching did + * not equal the expected set ( exactly one scrub + * notice for an invalid-UTF-8 selector, none + * otherwise ). + * - doing-it-wrong-missing: the _doing_it_wrong calls for an unparseable + * selector did not equal the expected set ( one + * select() notice per call, plus one leading + * scrub notice when the selector is invalid + * UTF-8 ). * - select-on-null: select() returned true for an unparseable selector. * - processor-error: the processor entered an error/unsupported state. * - case-determinism: running the full case twice gave different digests. @@ -672,6 +678,33 @@ static function () use ( $target, $selector_string, $html, $context ) { ); } + /** + * Flushes the select() parse caches. + * + * Both select() implementations memoize the most recently parsed selector + * string in a function-static cache, so whether a select() call re-parses + * — and therefore whether parse-time notices ( the invalid-UTF-8 scrub + * notice from from_selectors() ) fire — depends on what the worker + * happened to parse before. Parsing a sentinel selector first makes the + * next select() call for the case selector deterministic: it always + * re-parses, so exactly one parse happens inside each notice-assertion + * window regardless of worker history or case re-runs. + */ + private static function flush_select_parse_caches(): void { + ( new \WP_HTML_Tag_Processor( '' ) )->select( '#-fuzz-cache-flush-' ); + \WP_HTML_Processor::create_full_parser( '' )->select( '#-fuzz-cache-flush-' ); + } + + /** + * The _doing_it_wrong() name under which from_selectors() reports that an + * invalid-UTF-8 selector string was scrubbed to U+FFFD before parsing. + * + * @param string $target 'html' or 'tag'. + */ + private static function scrub_notice_name( string $target ): string { + return ( 'tag' === $target ? 'WP_CSS_Compound_Selector_List' : 'WP_CSS_Complex_Selector_List' ) . '::from_selectors'; + } + /** * Runs a select() loop on a parseable selector and compares the match set * against the reference matcher. @@ -680,6 +713,7 @@ static function () use ( $target, $selector_string, $html, $context ) { * @return string[]|null The actual match set, or null when matching failed. */ private static function check_select_matches( string $target, string $selector_string, array $document, array $expected, callable $record ): ?array { + self::flush_select_parse_caches(); Bootstrap::reset_doing_it_wrong(); list( $actual, $error ) = self::collect_matches( $target, $selector_string, $document ); @@ -695,13 +729,28 @@ private static function check_select_matches( string $target, string $selector_s return null; } + /* + * A selector string containing invalid UTF-8 is scrubbed to U+FFFD by + * from_selectors(), which reports the replacement with exactly one + * notice on the (single, cache-flushed) parse. Anything else is + * unexpected for a selector that parses. + */ + $expected_calls = \wp_is_valid_utf8( $selector_string ) + ? array() + : array( + array( + 'function' => self::scrub_notice_name( $target ), + ), + ); + $doing_it_wrong = Bootstrap::doing_it_wrong_calls(); - if ( array() !== $doing_it_wrong ) { + if ( ! self::notices_match( $expected_calls, $doing_it_wrong ) ) { $record( 'doing-it-wrong-unexpected', array( - 'target' => $target, - 'calls' => $doing_it_wrong, + 'target' => $target, + 'expectedCalls' => $expected_calls, + 'calls' => $doing_it_wrong, ) ); } @@ -754,7 +803,10 @@ private static function check_lexbor_differential( array $complex_ast, string $s * matching semantics, while byte-level parsing (escapes, whitespace, * modifier case — lexbor e.g. rejects uppercase I/S modifiers) is * covered by the AST round-trip and metamorphic invariants. ASTs - * containing invalid UTF-8 cannot be re-rendered and are skipped. + * containing invalid UTF-8 cannot be re-rendered; since + * from_selectors() scrubs input to U+FFFD before parsing, none should + * exist and this skip is defensive ( a nonzero skipped-utf8 tally + * indicates a normalization bypass ). */ if ( ! ast_strings_are_utf8( $complex_ast ) ) { return 'skipped-utf8'; @@ -946,6 +998,7 @@ static function () use ( $variant_list ) { * processor usable, and report misuse exactly once per call. */ private static function check_select_rejection( string $target, string $selector_string, array $document, callable $record ): void { + self::flush_select_parse_caches(); Bootstrap::reset_doing_it_wrong(); $context = ( $document['fragment'] ?? false ) ? $document['context'] : null; @@ -986,19 +1039,56 @@ static function () use ( $target, $selector_string, $document, $context ) { ); } + /* + * Two select() calls report the unparseable selector once each; the + * parse cache only skips re-parsing, never the per-call notice. An + * invalid-UTF-8 selector additionally reports the U+FFFD scrub once, + * on the first call ( the only one that parses after the flush ). + */ + $select_notice_name = ( 'tag' === $target ? 'WP_HTML_Tag_Processor' : 'WP_HTML_Processor' ) . '::select'; + $expected_calls = array( + array( 'function' => $select_notice_name ), + array( 'function' => $select_notice_name ), + ); + if ( ! \wp_is_valid_utf8( $selector_string ) ) { + array_unshift( $expected_calls, array( 'function' => self::scrub_notice_name( $target ) ) ); + } + $doing_it_wrong = Bootstrap::doing_it_wrong_calls(); - if ( 2 !== count( $doing_it_wrong ) ) { + if ( ! self::notices_match( $expected_calls, $doing_it_wrong ) ) { $record( 'doing-it-wrong-missing', array( 'target' => $target, - 'expectedCalls' => 2, + 'expectedCalls' => $expected_calls, 'calls' => $doing_it_wrong, ) ); } } + /** + * Compares recorded _doing_it_wrong() calls against expectations: same + * count, in order, matching on every key the expectation specifies + * ( recorded calls also carry 'message', which expectations omit ). + * + * @param array[] $expected_calls Expected calls, each a subset of record keys. + * @param array[] $actual_calls Recorded calls. + */ + private static function notices_match( array $expected_calls, array $actual_calls ): bool { + if ( count( $expected_calls ) !== count( $actual_calls ) ) { + return false; + } + foreach ( $expected_calls as $i => $expected_call ) { + foreach ( $expected_call as $key => $value ) { + if ( ( $actual_calls[ $i ][ $key ] ?? null ) !== $value ) { + return false; + } + } + } + return true; + } + /* * ------------- * Batch running From 2adef6a891ca6df3c154aede0a3c7656c0bb2d68 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 21:27:29 +0200 Subject: [PATCH 182/336] CSS selector fuzz: Cover the UTF-8 scrub with an invalid-utf8 bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core scrub (598ed6f363) decodes selector input before parsing: each maximal subpart of an ill-formed UTF-8 sequence becomes one U+FFFD (CSS Syntax 3 §3.2 via the WHATWG decoder). Until now only the chaos/mutated buckets exercised that path, organically and without AST expectations. The new bucket (weight 5 in both maps) injects one raw ill-formed sequence into a class/ID/attribute-name ident or quoted attribute string operand — lead/mid/trail/whole position, optionally behind a span type — and carries the post-scrub AST as its expectation. The per-class U+FFFD counts are pinned in INVALID_UTF8_CLASSES independently of wp_scrub_utf8(), so the AST round-trip is a real differential against the core scrub: lone continuation, truncated 2/3/4-byte leads, and invalid leads F5/FF decode to 1; overlong C0 80 / C1 BF to 2; surrogate half ED A0 80 to 3; beyond-max F4 90 80 80 to 4. An injected sequence is always followed by ASCII or end of input, so a continuation byte can never complete a truncated lead and shift the subpart boundaries. self-check gains a forced-bucket section (150 seeds): the selector must be invalid UTF-8, parse in both grammars, and parse to exactly the pinned AST; variety assertions require all subpart counts {1,2,3,4}, all four injection sites, and all ten byte classes. The class names and byte values are duplicated in the test deliberately — tallying from the generator's own table would shrink the assertion with a deleted entry and self-validate a drifted byte value (both demonstrated live in review). Adversarial review: three hostile reviewers. The spec reviewer verified the count table against an independently written WHATWG decoder (960 table contexts x 3 oracles; all 2880 site/position/class render combinations decode to the assumed post-scrub string; key-order-exact ASTs in both grammars; 3000-seed sweep clean). The test reviewer ran nine mutations — count drift, raw-byte expectations, suffix-guarantee removal, core scrub no-op, per-byte core scrub (killed exclusively by the two truncated classes that discriminate maximal-subpart from per-byte replacement), class deletion and de-selection, byte drift — all killed after two hardening rounds; two disclosed low-severity survivors remain (lone-continuation substring ambiguity; a class added to the table alone gets no variety pin). The integration reviewer confirmed the scrub-notice contract cannot flip (5200 constructed cases all invalid UTF-8), the lexbor differential stays live via the canonical re-render (zero skipped-utf8), digest determinism on every in-bucket seed in 1-400, and replay/minimizer behavior on raw-byte selectors. Gates: self-check OK; 5000 seeds, 0 failures (241 invalid-utf8 cases). --- tools/css-selector-fuzz/README.md | 9 +- .../lib/SelectorGenerator.php | 118 ++++++++++++++++++ tools/css-selector-fuzz/tests/self-check.php | 88 +++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 3af425719c807..3767c2a1726e8 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -25,7 +25,7 @@ produces the same document, the same selector, and the same verdict. capture on wild documents. Wild documents that hit a construct the processor bails on (foster parenting, complex adoption-agency runs) are deterministically regenerated a bounded number of times. -3. Generate a selector in one of seven buckets: +3. Generate a selector in one of nine buckets: - `supported-compound` — must parse in both grammars; carries intended AST. - `supported-complex` — uses `>`/descendant combinators; must parse only in the complex grammar; carries intended AST. @@ -49,6 +49,13 @@ produces the same document, the same selector, and the same verdict. and -elements, `+`/`~`/`||` combinators, namespaces, non-type context selectors); must not parse. - `invalid` — not valid CSS; must not parse. + - `invalid-utf8` — a small supported selector with a raw ill-formed UTF-8 + byte sequence (lone continuation, truncated 2/3/4-byte, overlong, + surrogate half, beyond U+10FFFF) injected into a class/ID/attribute + ident or string operand; `from_selectors()` scrubs the input first, so + the case must parse and carries the post-scrub AST (one U+FFFD per + maximal subpart, with per-class subpart counts pinned independently of + `wp_scrub_utf8()`). - `chaos` — arbitrary bytes; no parse expectation. - `mutated` — a supported selector with random byte mutations; no parse expectation. diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 1f3a8fa2e89c2..345228ed1a570 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -13,6 +13,11 @@ * (pseudo-classes/elements, sibling/column combinators, namespaces, * non-type context selectors). Must not parse in either grammar. * - invalid: not valid CSS selectors at all. Must not parse. + * - invalid-utf8: a supported compound with a raw ill-formed UTF-8 byte + * sequence injected into an ident or string operand. from_selectors() + * scrubs the input before parsing ( one U+FFFD per maximal subpart, CSS + * Syntax §3.2 via the WHATWG decoder ), so the case carries the + * post-scrub AST. * - chaos: arbitrary bytes. No parse expectation. * - mutated: a supported selector with random byte mutations. No parse * expectation. @@ -29,11 +34,35 @@ class SelectorGenerator { 'path-directed', 'unsupported', 'invalid', + 'invalid-utf8', 'chaos', 'mutated', 'edge-escape', ); + /** + * Ill-formed UTF-8 byte classes and the number of U+FFFD replacements the + * WHATWG UTF-8 decoder produces for each ( one per maximal subpart ). + * The counts are pinned here as an independent expectation — computing + * them with wp_scrub_utf8() would make the AST check a tautology. + * + * The counts assume the byte after the sequence is not a continuation + * byte ( it could complete a truncated sequence ); the generator always + * follows an injected sequence with ASCII or end of input. + */ + const INVALID_UTF8_CLASSES = array( + 'lone-continuation' => array( "\x80", 1 ), + 'truncated-2-byte' => array( "\xC3", 1 ), + 'truncated-3-byte' => array( "\xE2\x8C", 1 ), + 'truncated-4-byte' => array( "\xF0\x9F\x82", 1 ), + 'invalid-lead-f5' => array( "\xF5", 1 ), + 'invalid-lead-ff' => array( "\xFF", 1 ), + 'overlong-min' => array( "\xC0\x80", 2 ), + 'overlong-max' => array( "\xC1\xBF", 2 ), + 'surrogate-half' => array( "\xED\xA0\x80", 3 ), + 'beyond-max' => array( "\xF4\x90\x80\x80", 4 ), + ); + /** @var Prng */ private $prng; /** @var array */ @@ -182,6 +211,7 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, 'supported-complex' => 24, 'unsupported' => 14, 'invalid' => 11, + 'invalid-utf8' => 5, 'chaos' => 8, 'mutated' => 10, 'edge-escape' => 5, @@ -192,6 +222,7 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, 'path-directed' => 21, 'unsupported' => 11, 'invalid' => 9, + 'invalid-utf8' => 5, 'chaos' => 6, 'mutated' => 6, 'edge-escape' => 5, @@ -230,6 +261,9 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, case 'edge-escape': return $generator->gen_edge_escape(); + case 'invalid-utf8': + return $generator->gen_invalid_utf8(); + case 'unsupported': return array( 'bucket' => $bucket, @@ -764,6 +798,90 @@ private function gen_edge_escape(): array { ); } + /* + * ----------------------- + * Invalid-UTF-8 injection + * ----------------------- + * + * Raw ill-formed UTF-8 byte sequences in the selector input, mirroring + * the nul-input pattern: a small fixed simple selector keeps the case + * focused on the normalize_selector_input() scrub. Each maximal subpart + * of the injected sequence decodes to one U+FFFD ( per-class counts + * pinned in INVALID_UTF8_CLASSES ), and U+FFFD is a valid ident + * codepoint — including in start position — so the scrubbed selector + * must parse and the post-scrub AST is known by construction. + */ + private function gen_invalid_utf8(): array { + list( $bytes, $subparts ) = $this->prng->choice( array_values( self::INVALID_UTF8_CLASSES ) ); + + $position = $this->prng->choice( array( 'lead', 'mid', 'trail', 'whole' ) ); + $prefix = in_array( $position, array( 'lead', 'whole' ), true ) ? '' : 'a' . $this->prng->int( 0, 9 ); + $suffix = in_array( $position, array( 'trail', 'whole' ), true ) ? '' : 'z' . $this->prng->int( 0, 9 ); + $raw = $prefix . $bytes . $suffix; + $decoded = $prefix . str_repeat( "\u{FFFD}", $subparts ) . $suffix; + + switch ( $this->prng->choice( array( 'class', 'id', 'attr-name', 'attr-value' ) ) ) { + case 'class': + $rendered = '.' . $raw; + $sub = array( + 'kind' => 'class', + 'name' => $decoded, + ); + break; + + case 'id': + $rendered = '#' . $raw; + $sub = array( + 'kind' => 'id', + 'name' => $decoded, + ); + break; + + case 'attr-name': + $rendered = '[' . $raw . ']'; + $sub = array( + 'kind' => 'attr', + 'name' => $decoded, + 'matcher' => null, + 'value' => null, + 'modifier' => null, + ); + break; + + case 'attr-value': + default: + $name = 'a' . $this->prng->int( 0, 99 ); + $quote = $this->prng->chance( 50 ) ? '"' : "'"; + $rendered = '[' . $name . '=' . $quote . $raw . $quote . ']'; + $sub = array( + 'kind' => 'attr', + 'name' => $name, + 'matcher' => 'exact', + 'value' => $decoded, + 'modifier' => null, + ); + break; + } + + $type = $this->prng->chance( 40 ) ? 'span' : null; + + return array( + 'bucket' => 'invalid-utf8', + 'selector' => ( null === $type ? '' : $type ) . $rendered, + 'expectCompound' => true, + 'expectComplex' => true, + 'ast' => array( + array( + 'context' => array(), + 'self' => array( + 'type' => $type, + 'subs' => array( $sub ), + ), + ), + ), + ); + } + /* * ------------------------ * Path-directed generation diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index 10196f2d62291..d6a9a19dec2d0 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -91,6 +91,94 @@ function check( bool $condition, string $message ): void { check( count( $by_bucket ) >= 5, 'Bucket variety: saw ' . count( $by_bucket ) . ' buckets.' ); +// --- Invalid-UTF-8 bucket: post-scrub AST expectations by construction ------ +// from_selectors() replaces each maximal subpart of an ill-formed UTF-8 +// sequence with one U+FFFD before parsing ( CSS Syntax §3.2 via the WHATWG +// decoder ). The bucket injects raw ill-formed sequences and carries the +// post-scrub AST, with the per-class subpart counts hard-coded in the +// generator — independent of wp_scrub_utf8(), so this loop is a real +// differential between the generator's WHATWG expectations and the core +// scrub + parse pipeline. + +$fffd_ast_counts = array(); +$injection_sites = array(); +$byte_classes = array(); + +// The class names AND byte values are duplicated here on purpose: tallying +// from the generator's own table would silently shrink the assertion with a +// deleted entry and self-validate on a drifted byte value. +$expected_byte_classes = array( + 'lone-continuation' => "\x80", + 'truncated-2-byte' => "\xC3", + 'truncated-3-byte' => "\xE2\x8C", + 'truncated-4-byte' => "\xF0\x9F\x82", + 'invalid-lead-f5' => "\xF5", + 'invalid-lead-ff' => "\xFF", + 'overlong-min' => "\xC0\x80", + 'overlong-max' => "\xC1\xBF", + 'surrogate-half' => "\xED\xA0\x80", + 'beyond-max' => "\xF4\x90\x80\x80", +); + +$count_fffd = static function ( $node ) use ( &$count_fffd ): int { + if ( is_string( $node ) ) { + return substr_count( $node, "\u{FFFD}" ); + } + $total = 0; + if ( is_array( $node ) ) { + foreach ( $node as $child ) { + $total += $count_fffd( $child ); + } + } + return $total; +}; + +for ( $seed = 1; $seed <= 150; $seed++ ) { + $prng = new Prng( (string) $seed, 'self-check-invalid-utf8' ); + $document = DocumentGenerator::generate( $prng->fork( 'doc' ) ); + $case = SelectorGenerator::generate( $prng->fork( 'sel' ), $document['pools'], null, 'invalid-utf8' ); + $printable = \CssSelectorFuzz\printable_bytes( $case['selector'] ); + + check( 'invalid-utf8' === $case['bucket'], "Seed {$seed}: forced invalid-utf8 bucket, got {$case['bucket']}." ); + check( ! wp_is_valid_utf8( $case['selector'] ), "Seed {$seed}: selector must contain invalid UTF-8: {$printable}" ); + check( true === $case['expectCompound'] && true === $case['expectComplex'], "Seed {$seed}: invalid-utf8 cases must expect to parse in both grammars." ); + check( is_array( $case['ast'] ) && \CssSelectorFuzz\ast_strings_are_utf8( $case['ast'] ), "Seed {$seed}: expected AST must be valid UTF-8." ); + + $compound = WP_CSS_Compound_Selector_List::from_selectors( $case['selector'] ); + $complex = WP_CSS_Complex_Selector_List::from_selectors( $case['selector'] ); + check( null !== $compound, "Seed {$seed}: compound parse after scrub for: {$printable}" ); + check( null !== $complex, "Seed {$seed}: complex parse after scrub for: {$printable}" ); + if ( null === $complex || ! is_array( $case['ast'] ) ) { + continue; + } + + $parsed_ast = \CssSelectorFuzz\AstExtractor::from_complex_list( $complex ); + check( $case['ast'] === $parsed_ast, "Seed {$seed}: parsed AST equals maximal-subpart scrub expectation for: {$printable}" ); + + $fffd_ast_counts[ $count_fffd( $case['ast'] ) ] = true; + foreach ( (array) $case['ast'][0]['self']['subs'] as $sub ) { + $injection_sites[ 'attr' === $sub['kind'] && null !== $sub['matcher'] ? 'attr-value' : $sub['kind'] ] = true; + } + foreach ( $expected_byte_classes as $class_name => $class_bytes ) { + // Substring attribution is ambiguous only for lone-continuation, + // whose byte occurs inside three longer classes — good enough for + // an at-least-once variety tally. + if ( str_contains( $case['selector'], $class_bytes ) ) { + $byte_classes[ $class_name ] = true; + } + } +} + +foreach ( array( 1, 2, 3, 4 ) as $expected_count ) { + check( isset( $fffd_ast_counts[ $expected_count ] ), "Invalid-utf8 variety: a {$expected_count}-subpart byte class was generated." ); +} +foreach ( array( 'class', 'id', 'attr', 'attr-value' ) as $site ) { + check( isset( $injection_sites[ $site ] ), "Invalid-utf8 variety: injection site {$site} was generated." ); +} +foreach ( array_keys( $expected_byte_classes ) as $class_name ) { + check( isset( $byte_classes[ $class_name ] ), "Invalid-utf8 variety: byte class {$class_name} was generated." ); +} + // --- Known-answer matching cases ------------------------------------------- $known_html = '' From 5ddac66b92892031feed967191ec0fc0fa56e0d4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 21:37:38 +0200 Subject: [PATCH 183/336] CSS selector fuzz: Splice raw invalid UTF-8 in the mutation bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutated bucket's operations drew from a pure-ASCII alphabet, so the only ill-formed UTF-8 it produced came from delete/duplicate corrupting the pools' few multibyte characters (leads C3/CE/E2/F0 only). A new mutation kind (weight 12) splices one raw sequence from INVALID_UTF8_CLASSES at an arbitrary byte offset — possibly splitting an existing multibyte character or landing where a following continuation byte re-validates the string. These cases carry no AST expectation; they exercise crash, scrub-notice, and differential paths, and they make the worker's invalid-UTF-8 rejection branch hot (an unparseable invalid-UTF-8 selector expects scrub + two select() notices), which no bucket reached before: the invalid-utf8 bucket always parses and the chaos alphabets are valid UTF-8. self-check asserts the operation fires: at least 10 of 200 forced mutated seeds must contain a marker byte C0/C1/ED/F4/F5/FF (currently 28). The marker set is exactly the sound subset: those bytes cannot occur in any clean render (C0/C1/F5/FF never appear in valid UTF-8; ED/F4 only for U+D000-D7FF / above U+FFFFF, which no pool emits), while the four marker-free sequences (80, C3, E2 8C, F0 9F 82) reuse bytes that legitimate pool characters contain. Adversarial review: the same three hostile reviewers, all approved. Spec: splice arithmetic verified at every boundary (empty selector, at=0/length, cross-round corruption), a 20000-seed crash sweep with warnings escalated to exceptions came back clean, marker exclusivity confirmed against the pre-change generator (0 hits in 20000 seeds; the red loop reproduced exactly). Test adequacy: dead arm, dead weighted entry, and empty payload all collapse to 0/200 against the 28/200 baseline (threshold ~4 sigma below the mean under PRNG reshuffles); the one survivor (dropping only marker-free payloads) is probe diversity, not verification, and the bucket commit pins all ten classes. Integration: 5000 seeds 0 failures with byte-identical bucket distribution to the pre-change baseline, the notice contract verified self-keyed on the final byte string under 11 adversarial splice shapes including validity-restoring ones, determinism on every mutated seed in 1-400. Gates: self-check OK; 5000 seeds, 0 failures. --- tools/css-selector-fuzz/README.md | 3 ++- .../lib/SelectorGenerator.php | 22 ++++++++++++++----- tools/css-selector-fuzz/tests/self-check.php | 18 +++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 3767c2a1726e8..f0abff0934da5 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -57,7 +57,8 @@ produces the same document, the same selector, and the same verdict. maximal subpart, with per-class subpart counts pinned independently of `wp_scrub_utf8()`). - `chaos` — arbitrary bytes; no parse expectation. - - `mutated` — a supported selector with random byte mutations; no parse + - `mutated` — a supported selector with random byte mutations, including + raw ill-formed UTF-8 splices at arbitrary byte offsets; no parse expectation. - `edge-escape` — selectors that exercise otherwise-unreachable parser branches: hex escapes for NUL / surrogate / over-max codepoints (must diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 345228ed1a570..fb93f8d66216d 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -1666,11 +1666,12 @@ private function mutate( string $selector ): string { $length = strlen( $selector ); $kind = $this->prng->weighted( array( - 'insert' => 30, - 'delete' => 25, - 'replace' => 25, - 'duplicate' => 10, - 'case-flip' => 10, + 'insert' => 30, + 'delete' => 25, + 'replace' => 25, + 'duplicate' => 10, + 'case-flip' => 10, + 'invalid-utf8' => 12, ) ); @@ -1714,6 +1715,17 @@ private function mutate( string $selector ): string { $selector = substr( $selector, 0, $at ) . $flip . substr( $selector, $at + 1 ); } break; + + case 'invalid-utf8': + // Splice a raw ill-formed sequence at an arbitrary byte + // offset — possibly splitting an existing multibyte + // character or landing before a continuation byte that + // completes a truncated lead. No expectations here; these + // exercise crash / scrub-notice / differential paths. + $bytes = $this->prng->choice( array_column( self::INVALID_UTF8_CLASSES, 0 ) ); + $at = $this->prng->int( 0, $length ); + $selector = substr( $selector, 0, $at ) . $bytes . substr( $selector, $at ); + break; } } diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index d6a9a19dec2d0..8d9df7e378b70 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -179,6 +179,24 @@ function check( bool $condition, string $message ): void { check( isset( $byte_classes[ $class_name ] ), "Invalid-utf8 variety: byte class {$class_name} was generated." ); } +// --- Mutated bucket: raw invalid-byte splicing ------------------------------- +// mutate() must be able to splice raw ill-formed UTF-8 into a selector at +// arbitrary byte offsets; these cases carry no AST expectation and exercise +// crash / scrub-notice / differential paths only. The marker bytes here can +// appear in NO rendered selector (the pools' multibyte characters use other +// lead bytes), so their presence proves the mutation operation fired. + +$mutated_with_invalid = 0; +for ( $seed = 1; $seed <= 200; $seed++ ) { + $prng = new Prng( (string) $seed, 'self-check-mutated-utf8' ); + $document = DocumentGenerator::generate( $prng->fork( 'doc' ) ); + $case = SelectorGenerator::generate( $prng->fork( 'sel' ), $document['pools'], null, 'mutated' ); + if ( false !== strpbrk( $case['selector'], "\xC0\xC1\xED\xF4\xF5\xFF" ) ) { + ++$mutated_with_invalid; + } +} +check( $mutated_with_invalid >= 10, "Mutated bucket splices raw invalid bytes ({$mutated_with_invalid} of 200 seeds)." ); + // --- Known-answer matching cases ------------------------------------------- $known_html = '' From 79691ca65461d3a7e6abdbcd974dd537363a1296 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 22:02:07 +0200 Subject: [PATCH 184/336] CSS selector fuzz: Record the lexbor invalid-byte probe and refresh docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff's open question — what does lexbor do with raw ill-formed UTF-8 in selectors — is resolved empirically: lexbor v3.0.0 accepts the bytes (no parse error) and replaces them with U+FFFD, but not per the WHATWG maximal-subpart rule CSS Syntax 3 §3.2 invokes. Truncated multi-byte sequences decode to one U+FFFD per byte (E2 8C to 2, spec 1; F0 9F 82 to 3, spec 1) and UTF-8-encoded surrogate halves decode permissively as a single unit (ED A0 80 to 1, spec 3); agreement on the other byte classes is coincidental overlap of the two algorithms. Drafted as UPSTREAM-ISSUES.md issue 6 with the probe table. On the document side lexbor keeps raw invalid bytes unchanged in the DOM (the same stance as the Tag Processor), so raw doc bytes match nothing in either engine. The differential needs no n/a gating for the invalid-utf8 bucket: the worker hands lexbor a canonical re-render of the post-scrub AST (pure ASCII), the same mechanism that sidesteps lexbor's other byte-level parsing bugs, so the bucket compares normally. NEXT-STEPS.md: the deferred scrub-coverage item resolves as implemented (bucket, splice, probe); the handoff's optional metamorphic relation parse(s) === parse(scrub(s)) is recorded as deliberately skipped (no public path bypasses from_selectors(), so it is near-tautological). New small open item from review: gen_chaos()'s whole-codepoint unicode branch is dead code (string-vs-key comparison), and its byte-sliced fallback is what makes chaos emit invalid UTF-8 organically (~15% of chaos cases) — making the branch live is a behavior decision now that deliberate ill-formed coverage exists. COVERAGE.md regenerated at the 3000-seed window: 396/424 = 93.4% raw, 408/424 = 96.2% effective. All 28 unreached lines accounted for: 12 phpdbg case-label artifacts, 12 defensive guards, the 2-line escape-decoder invalid-byte arm the scrub made unreachable through from_selectors() (pinned by PHPUnit), and 2 reachable lines this window misses (witnesses verified under phpdbg: '[' reaches the attribute length guard, '[a="b' reaches the string-to-EOF break). Stale 93.8%/96.8% references in NEXT-STEPS.md and FINDINGS.md updated to point at COVERAGE.md as the source of truth. Adversarial review: the same three hostile reviewers, all approved after two correction rounds. The issue-6 table was reproduced 10/10 rows against the pinned harness by two reviewers independently, the WHATWG column confirmed against an independent spec-transcribed decoder, and the ED-restricts-its-first-continuation-to-9F subpart reasoning checked against the Encoding Standard's ranges. The coverage table and uncovered-line list reproduced exactly; the case-label artifact demonstrated mechanistically (executable-but-unloggable label lines with executing bodies); the un-normalized-only claim verified in both directions (direct parse_ident hits the arm, from_selectors never does). Corrections from review: the issue-6 legend mislabeled U+FFFD counts as byte counts; stale coverage numbers contradicted the regenerated report; chaos's organic invalid UTF-8 was misattributed to pool corruption (it byte-slices its unicode alphabet); an 'all production moves' claim ignored mutated's residual organic corruption (~2% of pre-splice mutated cases). Gates: self-check OK; docs-only change — code identical to 8333b9347b, whose 5000-seed run was clean. --- tools/css-selector-fuzz/COVERAGE.md | 73 +++++++++++++------ tools/css-selector-fuzz/FINDINGS.md | 4 +- tools/css-selector-fuzz/NEXT-STEPS.md | 47 ++++++++++-- .../lexbor/UPSTREAM-ISSUES.md | 63 +++++++++++++++- 4 files changed, 153 insertions(+), 34 deletions(-) diff --git a/tools/css-selector-fuzz/COVERAGE.md b/tools/css-selector-fuzz/COVERAGE.md index 5279d0363201e..e7b715c0a8894 100644 --- a/tools/css-selector-fuzz/COVERAGE.md +++ b/tools/css-selector-fuzz/COVERAGE.md @@ -7,25 +7,27 @@ with phpdbg's opcode log over 3000 deterministic seeds: | file | covered / executable | % | |---|---|---| -| class-wp-css-attribute-selector.php | 106 / 116 | 91.4% | +| class-wp-css-attribute-selector.php | 108 / 119 | 90.8% | | class-wp-css-class-selector.php | 10 / 10 | 100% | | class-wp-css-complex-selector-list.php | 16 / 16 | 100% | | class-wp-css-complex-selector.php | 59 / 66 | 89.4% | | class-wp-css-compound-selector-list.php | 27 / 28 | 96.4% | | class-wp-css-compound-selector.php | 29 / 32 | 90.6% | | class-wp-css-id-selector.php | 12 / 12 | 100% | -| class-wp-css-selector-parser-matcher.php | 110 / 111 | 99.1% | +| class-wp-css-selector-parser-matcher.php | 120 / 124 | 96.8% | | class-wp-css-type-selector.php | 15 / 17 | 88.2% | -| **TOTAL** | **384 / 408** | **94.1%** | +| **TOTAL** | **396 / 424** | **93.4%** | -The 24 unreached lines are all accounted for below. Twelve are a phpdbg -measurement artifact (the code executes); the other twelve are defensive -guards that the public entry points cannot reach. Effective coverage of -reachable code is **396 / 408 = 97.1%**. +The 28 unreached lines are all accounted for below: twelve are a phpdbg +measurement artifact (the code executes), twelve are defensive guards the +public entry points cannot reach, two are the escape decoder's +invalid-byte arm that the input scrub made unreachable through +`from_selectors()`, and two are reachable lines this seed window happens +to miss. Counting the artifact lines as covered, effective coverage is +**408 / 424 = 96.2%**. -(Executable-line totals grew from 401 to 408 with the EOF-escape and -EOF-auto-close changes; two parser-matcher EOF guards that used to be -unreachable defensive lines are now genuinely exercised — see below.) +(Executable-line totals grew from 408 to 424 with the case-insensitive +attribute value list and the invalid-UTF-8 scrub changes.) ## phpdbg `case`-label artifact (12 lines — code executes) @@ -36,8 +38,8 @@ lexbor differential + self-check confirm the corresponding behavior). These are not real gaps: - `class-wp-css-attribute-selector.php` - - 309, 313, 317, 321, 325 — the `~= |= ^= $= *=` matcher operators. - - 350, 351, 356, 357 — the `i`/`I`/`s`/`S` case modifiers. + - 378, 382, 386, 390, 394 — the `~= |= ^= $= *=` matcher operators. + - 419, 420, 425, 426 — the `i`/`I`/`s`/`S` case modifiers. - `class-wp-css-compound-selector.php` - 120, 122, 124 — the `.` / `#` / `[` subclass-selector dispatch. @@ -46,7 +48,7 @@ are not real gaps: These are internal precondition checks that the calling code already guarantees, or branches for grammar the parser never emits: -- `class-wp-css-attribute-selector.php:282` — `return null` when the first +- `class-wp-css-attribute-selector.php:351` — `return null` when the first byte is not `[`. `parse()` is only ever called by `parse_subclass_selector()` *after* it has matched `[`, so the guard never fires. @@ -54,11 +56,11 @@ guarantees, or branches for grammar the parser never emits: "unsupported combinator" arm in the match walker. The parser only ever stores `' '` (descendant) or `'>'` (child) combinators, so the match-time default arm is dead defensively. -- `class-wp-css-compound-selector-list.php:87` — `return false` when the +- `class-wp-css-compound-selector-list.php:107` — `return false` when the processor is not on a `#tag` token. `select()` only invokes matching while positioned on a tag; reachable only by calling `matches()` directly off a non-tag token. -- `class-wp-css-selector-parser-matcher.php:351` — +- `class-wp-css-selector-parser-matcher.php:375` — `next_two_are_valid_escape()` EOF guard; every caller either bound-checks first or only calls it on a known backslash byte. - `class-wp-css-type-selector.php:45` — `return false` when `get_tag()` is @@ -66,11 +68,30 @@ guarantees, or branches for grammar the parser never emits: - `class-wp-css-type-selector.php:75` — `parse()` EOF guard; the compound parser checks `offset < strlen` before calling. -Two guards documented here in earlier revisions are now genuinely covered: -the `parse_string()` EOF guard and the -`check_if_three_code_points_would_start_an_ident_sequence()` EOF guard are -both reached since EOF auto-close lets `[a=` call the value parsers at the -end of input. +## Un-normalized input only (2 lines — pinned by PHPUnit) + +- `class-wp-css-selector-parser-matcher.php:287–288` — the escape decoder's + invalid-byte arm (consume the maximal subpart `_wp_scan_utf8()` reported, + return one U+FFFD). The invalid-UTF-8 scrub in `normalize_selector_input()` + made this arm structurally unreachable through `from_selectors()` — the + fuzzer's only entry point — and it exists for direct `parse()` callers + with un-normalized input. The escape pins in + `tests/phpunit/tests/html-api/wpCssSelectorParserMatcher.php` exercise it + for every invalid-byte decode class under the U+2603 canary. + +## Reachable, but missed by this seed window (2 lines) + +Both lines are demonstrably reachable (witnesses verified directly under +phpdbg) but sit behind enough generator coin flips that a fixed 3000-seed +window may or may not sample them; earlier revisions of this report saw +them flicker in and out across windows: + +- `class-wp-css-attribute-selector.php:345` — the `[x` minimum-length + guard; witness: `[` (also `.a[`, `a[`) at the end of input. +- `class-wp-css-selector-parser-matcher.php:149` — `parse_string()`'s + break when plain string content runs to end of input; witness: `[a="b` + (the `eof-truncated` edge-escape kind reaches it only when its quote-drop + and no-backslash coins both land, ~1 expected case per 3000 seeds). ## Notes on what raised coverage @@ -82,9 +103,17 @@ end of input. kind covers the EOF auto-close paths in the attribute parser, including unterminated strings (with and without a trailing "do nothing" backslash, which keeps the `parse_string` backslash-at-EOF arm exercised). +- The `invalid-utf8` bucket and the `mutated` bucket's raw-byte splice + drive the `wp_scrub_utf8()` replacement branch and its + `_doing_it_wrong()` notice in `normalize_selector_input()` + deterministically (previously hit only organically — `chaos` + byte-slicing its multibyte `unicode` alphabet, `mutated` corrupting the + pools' few multibyte characters), and the splice makes the + unparseable-and-invalid-UTF-8 notice ordering in the worker hot. - A few `invalid`-bucket templates (`[a=`, `[a~`, `[a="bc`, `a.`) reach attribute / string / class parse guards that random structural generation - rarely lands on. With them the per-file numbers above are **deterministic** + rarely lands on. With them the per-file numbers above are stable at the documented 3000-seed window (e.g. `class-wp-css-class-selector.php` reaches 10/10 reliably rather than depending on whether a bare `.` happened - to be sampled). + to be sampled) — apart from the two borderline-frequency lines listed + above. diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index 5e650aac73549..ca408ad84c591 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -163,8 +163,8 @@ Implemented and validated: - **Parser-derived oracle tree** (`TreeCapture`): the processor's own parse is ground truth, so **wild / restructured HTML** and **`` fragments** are fuzzed, not only clean trees. -- **Line coverage** measured (93.8%, see `COVERAGE.md`; 96.8% of reachable - code, remainder justified). +- **Line coverage** measured (93.4%, see `COVERAGE.md` — the source of truth + for current numbers; 96.2% effective, remainder justified). - **Automatic minimizer** (`minimize.php`): delta-debugs selector and HTML to a minimal reproducer preserving a chosen signature. diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index b84ec6130f47a..dd3f52d8e774b 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -2,7 +2,8 @@ > **Status: all seven work items below are implemented and validated** (see > `README.md`, `COVERAGE.md`, `FINDINGS.md`). The acceptance bar is met: -> coverage measured (93.8%; 96.8% of reachable code, remainder justified); +> coverage measured (93.4%; 96.2% effective — `COVERAGE.md` is the source +> of truth for the current numbers, remainder justified); > three oracles agree on no-quirks supported cases with every divergence > triaged; metamorphic invariants passing; combinator positive-match rate > raised from 14.5% to ~68% (path-directed bucket); minimizer working; a clean @@ -148,15 +149,45 @@ > its quadratic tail were removed by the scrub implementation — see the > invalid-UTF-8 policy entry above.) > +> **Fuzzer coverage for the scrub surface — IMPLEMENTED (2026-06-11):** +> the deferred coverage work for the invalid-UTF-8 scrub landed in three +> pieces. (1) A dedicated `invalid-utf8` generator bucket injects raw +> ill-formed sequences into class/ID/attribute-name idents and quoted +> string operands and carries the post-scrub AST; the per-class maximal- +> subpart U+FFFD counts are pinned independently of `wp_scrub_utf8()` +> (self-check additionally duplicates the class names and byte values, so +> a deleted or drifted table entry fails instead of shrinking the +> assertion). (2) A `mutated`-bucket splice kind inserts raw ill-formed +> sequences at arbitrary byte offsets — no expectations, but it makes the +> worker's invalid-UTF-8 rejection branch hot (scrub + two `select()` +> notices), which no other bucket reached. (3) The explicit lexbor probe: +> lexbor accepts raw invalid selector bytes and replaces them with U+FFFD, +> but NOT per the WHATWG maximal-subpart rule — one U+FFFD per byte for +> truncated sequences (`E2 8C` → 2, spec 1) and one per whole sequence for +> UTF-8-encoded surrogate halves (`ED A0 80` → 1, spec 3) — drafted as +> `lexbor/UPSTREAM-ISSUES.md` issue 6. The differential is unaffected and +> stays live for the bucket: it feeds lexbor the canonical re-render of +> the post-scrub AST (escaped, pure ASCII), the same mechanism that +> sidesteps lexbor's other byte-level parsing bugs. Doc-side observation: +> lexbor keeps raw invalid bytes in the DOM unchanged (same stance as the +> Tag Processor), so raw doc bytes match nothing in either engine. The +> handoff's optional metamorphic relation `parse(s) === parse(scrub(s))` +> was skipped deliberately: it is near-tautological (it could only catch +> a `from_selectors()` bypass, and no public path bypasses it). +> > **Still open from the original follow-up list:** the tooling items in > this file's hardening notes (self-check decoupling, class-NUL injection, -> vacuous-assertion rate, quirks-mode single-oracle gap), plus deferred -> fuzzer coverage for the scrub surface (dedicated invalid-UTF-8 generator -> bucket with maximal-subpart AST expectations, raw-byte mutation class, -> explicit lexbor invalid-byte probe — handoff drafted 2026-06-11; note -> the chaos/mutated buckets already produce invalid-UTF-8 selectors -> organically and lexbor agreed with the scrubbed results across a clean -> 5000-seed run). +> vacuous-assertion rate, quirks-mode single-oracle gap). New small item +> from the 2026-06-11 review: `gen_chaos()`'s whole-codepoint `unicode` +> branch is dead code — it compares the alphabet *string* against the key +> `'unicode'` after the value lookup already happened — so the unicode +> alphabet is byte-sliced by the generic fallback instead. That slicing is +> what makes chaos emit invalid UTF-8 organically (~15% of chaos cases), +> so making the branch live is a behavior decision, not just a cleanup: +> it would remove chaos's organic ill-formed-byte production, leaving the +> deliberate paths (`invalid-utf8` bucket, `mutated` splice) plus +> `mutated`'s residual organic corruption of pool multibyte characters +> (~2% of mutated cases even without the splice). Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`). diff --git a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md index 9ec23e414dcfa..ef167f2814959 100644 --- a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md +++ b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md @@ -1,10 +1,12 @@ # lexbor — draft upstream bug reports -Five spec-conformance bugs in liblexbor's CSS selectors support, found while +Six spec-conformance bugs in liblexbor's CSS selectors support, found while using lexbor as a differential oracle for the WordPress HTML-API CSS selector fuzzer (`tools/css-selector-fuzz/`). Issues 1–3 were re-verified directly against the harness on 2026-06-10; issues 4–5 surfaced during the WP -conformance-fix session and were re-verified on 2026-06-11. +conformance-fix session and were re-verified on 2026-06-11; issue 6 came out +of the explicit invalid-byte probe for the WP scrub coverage work +(2026-06-11). - **Pinned version:** lexbor v3.0.0 (`2ae88a1c6b52`), built by `tools/css-selector-fuzz/lexbor/build.sh`. @@ -217,3 +219,60 @@ a feature request rather than a bug if lexbor considers document-language selector rules out of scope for its selectors module — but lexbor is an HTML engine and browsers uniformly implement the folding, so matching against HTML documents diverges from every browser without it. + +## Issue 6 — ill-formed UTF-8 in selectors is not decoded per the Encoding Standard + +CSS Syntax Level 3 decodes the input byte stream via the Encoding Standard +before tokenizing: + +> To decode bytes, ... Otherwise, decode bytes with fallback encoding utf-8. +> — https://www.w3.org/TR/css-syntax-3/#input-byte-stream (§3.2) + +The Encoding Standard's UTF-8 decoder replaces each **maximal subpart of an +ill-formed subsequence** with a single U+FFFD (the boundaries follow the +decoder's byte-range tables; see also Unicode §3.9 "U+FFFD Substitution of +Maximal Subparts"): + +> https://encoding.spec.whatwg.org/#utf-8-decoder + +lexbor accepts raw ill-formed bytes in selectors (no parse error) and +replaces them with U+FFFD, but with different boundaries: a truncated +multi-byte sequence yields one U+FFFD **per byte** instead of one per +maximal subpart, and a UTF-8-encoded surrogate half (`ED A0 80`–`ED BF +BF`) is decoded permissively as a **single unit** yielding one U+FFFD +instead of three. Verified at v3.0.0 by matching raw-byte class selectors +against elements whose class attributes contain literal U+FFFD runs +(`
` = 1×U+FFFD ... `
` = 4×U+FFFD; +`�` below is U+FFFD, U+FFFD counts in parentheses): + +| selector bytes | WHATWG decode | lexbor | +|-----------------------|----------------|----------------| +| `.ab` | `a�b` (1) ✅ | `a��b` (2) ❌ | +| `.ab` | `a�b` (1) ✅ | `a���b` (3) ❌ | +| `.ab` | `a���b` (3) ✅ | `a�b` (1) ❌ | +| `.ab` | `a���b` (3) ✅ | `a�b` (1) ❌ | +| `.a<80>b` | `a�b` (1) | `a�b` (1) ✅ | +| `.ab` | `a�b` (1) | `a�b` (1) ✅ | +| `.ab` | `a��b` (2) | `a��b` (2) ✅ | +| `.ab` | `a���b` (3) | `a���b` (3) ✅ | +| `.ab` | `a����b` (4) | `a����b` (4) ✅ | + +The agreeing rows are controls where per-byte replacement coincides with +the maximal-subpart rule (lone continuation/lead bytes, overlongs whose +subparts are all single bytes, beyond-U+10FFFF). The same behavior applies +inside string tokens (`[x="p<80>q"]` matches `x="p�q"`). Two truncated +sequences are exactly where the algorithms separate: `E2 8C` is **one** +maximal subpart (E2 accepts two continuations and 8C is a valid first +continuation), while `ED A0` is **not** a subpart at all (ED restricts its +first continuation to 80–9F), so `ED A0 80` is three. + +Notes for the filing agent: browsers only exercise this decode through the +stylesheet byte stream (JS `querySelectorAll` strings are already UTF-16), +so compare against an external stylesheet with raw bytes, or against +another Encoding Standard implementation (e.g. `TextDecoder('utf-8')`, +whose output for the byte sequences above shows the maximal-subpart +boundaries directly). Document-side context: lexbor stores raw ill-formed +bytes from the HTML byte stream unchanged in the DOM (a raw `<80>` in a +class attribute is matched by no selector, not even one with the same raw +bytes), so the repro must put literal U+FFFD characters in the document +and raw bytes only in the selector. From 42c267a642ae46bac2298bf35f11e8059e74b352 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 11 Jun 2026 23:48:05 +0200 Subject: [PATCH 185/336] Build lexbor harness from master --- tools/css-selector-fuzz/NEXT-STEPS.md | 6 +++--- tools/css-selector-fuzz/README.md | 10 ++++----- .../lexbor/UPSTREAM-ISSUES.md | 12 +++++------ tools/css-selector-fuzz/lexbor/build.sh | 21 ++++++++++++------- tools/css-selector-fuzz/lexbor/harness.c | 4 ++-- tools/css-selector-fuzz/lib/LexborOracle.php | 4 ++-- 6 files changed, 31 insertions(+), 26 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index dd3f52d8e774b..36988221a90fe 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -292,9 +292,9 @@ case-sensitively in no-quirks (`WP_HTML_Tag_Processor::is_quirks_mode()`); type names are always case-insensitive. Do NOT trust lexbor on quirks-mode case behavior. Restrict the lexbor differential to **no-quirks documents** (emit ``), and keep `ReferenceMatcher` as the authority for the -quirks-mode path. Pin the exact lexbor version used and note whether #368 is -fixed in it. Re-evaluate enabling quirks comparison only after verifying lexbor's -behavior against that issue. +quirks-mode path. Record the exact lexbor master commit used and note whether +#368 is fixed in it. Re-evaluate enabling quirks comparison only after verifying +lexbor's behavior against that issue. - Also surface (don't auto-fail) **attribute default case-insensitivity**: Selectors-4/HTML define a set of attributes matched case-insensitively by diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index f0abff0934da5..cfcfda666cf7e 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -106,9 +106,9 @@ produces the same document, the same selector, and the same verdict. ## lexbor harness Build with `sh tools/css-selector-fuzz/lexbor/build.sh` (clones and builds -liblexbor, pinned to v3.0.0 = `2ae88a1c6b52`). The worker auto-detects the -binary at `tools/css-selector-fuzz/lexbor/harness` and reports per-batch -tallies, persisted to `state.json` under `lexbor`: +liblexbor from upstream `master`; the build script prints the exact commit). +The worker auto-detects the binary at `tools/css-selector-fuzz/lexbor/harness` +and reports per-batch tallies, persisted to `state.json` under `lexbor`: - `compared` — the differential ran and matched fid-multisets. - `tree-gated` — WP and lexbor built different trees; differential skipped. @@ -119,9 +119,9 @@ tallies, persisted to `state.json` under `lexbor`: a loud warning if these appear after the harness had run, so a third oracle that dies mid-run cannot hide behind a green run. -Known lexbor issues compensated for at this pin: +Known lexbor issues compensated for when present: -- [#368](https://github.com/lexbor/lexbor/issues/368) (open at v3.0.0): +- [#368](https://github.com/lexbor/lexbor/issues/368): class and `#id` selectors match ASCII case-insensitively even in no-quirks documents (`[id=…]` attribute matching is correctly case-sensitive). Detected by a startup probe; when present, lexbor is diff --git a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md index ef167f2814959..40c2ccb80f97c 100644 --- a/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md +++ b/tools/css-selector-fuzz/lexbor/UPSTREAM-ISSUES.md @@ -8,8 +8,9 @@ conformance-fix session and were re-verified on 2026-06-11; issue 6 came out of the explicit invalid-byte probe for the WP scrub coverage work (2026-06-11). -- **Pinned version:** lexbor v3.0.0 (`2ae88a1c6b52`), built by - `tools/css-selector-fuzz/lexbor/build.sh`. +- **Default build target:** lexbor upstream `master`, built by + `tools/css-selector-fuzz/lexbor/build.sh`. Record the exact commit printed + by the build script when verifying any issue. - **Upstream repo:** https://github.com/lexbor/lexbor - **Already filed upstream — do NOT refile:** [#368](https://github.com/lexbor/lexbor/issues/368) (class/`#id` selectors @@ -17,10 +18,9 @@ of the explicit invalid-byte probe for the WP scrub coverage work ## Instructions for the filing agent -1. **Re-verify at lexbor master first.** The pin is v3.0.0; any of these may - already be fixed. Edit `build.sh` to build master (or clone/build manually) - and re-run the repros below. Only file what still reproduces, and say in - the report which commit you tested. +1. **Re-verify at current lexbor master first.** Any of these may already be + fixed. Run `build.sh` and re-run the repros below. Only file what still + reproduces, and say in the report which commit you tested. 2. **Search for duplicates** before filing (suggested queries: `~=`, `attr-modifier`, `case insensitive modifier`, `ident code point`, `U+00B7`, `non-ascii`, `EOF`, `unclosed`, `simple block`, diff --git a/tools/css-selector-fuzz/lexbor/build.sh b/tools/css-selector-fuzz/lexbor/build.sh index 186a7e5b703b6..a1471675bc815 100644 --- a/tools/css-selector-fuzz/lexbor/build.sh +++ b/tools/css-selector-fuzz/lexbor/build.sh @@ -2,10 +2,11 @@ # # Builds the lexbor differential harness. # -# Pinned lexbor version: v3.0.0 (2ae88a1c6b5261830eff73ee12bb3cdf805f3cfe). +# Builds against upstream lexbor master. The exact commit is printed after +# each build and recorded in the build cache. # Note: lexbor issue #368 ("Class/ID selectors are ASCII case-insensitive -# even in no-quirks mode") is still OPEN at this version; the PHP adapter -# detects it at startup and compensates (see LexborOracle.php). +# even in no-quirks mode") is detected at startup and compensated for when +# present (see LexborOracle.php). # # Usage: # sh tools/css-selector-fuzz/lexbor/build.sh [lexbor-src-dir] @@ -16,27 +17,31 @@ set -e HERE="$(cd "$(dirname "$0")" && pwd)" SRC="${1:-/tmp/lexbor-src}" -PIN="2ae88a1c6b5261830eff73ee12bb3cdf805f3cfe" +BRANCH="master" if [ ! -d "$SRC" ]; then echo "Cloning lexbor into $SRC ..." git clone https://github.com/lexbor/lexbor "$SRC" fi -git -C "$SRC" checkout --quiet "$PIN" +git -C "$SRC" fetch --quiet origin "$BRANCH" +git -C "$SRC" checkout --quiet -B "$BRANCH" "origin/$BRANCH" +REV="$(git -C "$SRC" rev-parse --verify HEAD)" +STAMP="$SRC/build/.lexbor-rev" -if [ ! -f "$SRC/build/liblexbor_static.a" ]; then - echo "Building liblexbor_static ..." +if [ ! -f "$SRC/build/liblexbor_static.a" ] || [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$REV" ]; then + echo "Building liblexbor_static ($BRANCH $REV) ..." mkdir -p "$SRC/build" cd "$SRC/build" cmake -DCMAKE_BUILD_TYPE=Release -DLEXBOR_BUILD_SHARED=OFF \ -DLEXBOR_BUILD_STATIC=ON -DLEXBOR_BUILD_TESTS=OFF \ -DLEXBOR_BUILD_EXAMPLES=OFF .. > /dev/null make -j8 lexbor_static > /dev/null + printf '%s\n' "$REV" > "$STAMP" cd "$HERE" fi cc -O2 -Wall -Wextra -o "$HERE/harness" "$HERE/harness.c" \ -I "$SRC/source" "$SRC/build/liblexbor_static.a" -echo "Built $HERE/harness (lexbor $PIN)" +echo "Built $HERE/harness (lexbor $BRANCH $REV)" diff --git a/tools/css-selector-fuzz/lexbor/harness.c b/tools/css-selector-fuzz/lexbor/harness.c index ebd3aa32f4b4a..582c387a92f86 100644 --- a/tools/css-selector-fuzz/lexbor/harness.c +++ b/tools/css-selector-fuzz/lexbor/harness.c @@ -21,8 +21,8 @@ * for elements without one (matching the fuzzer's placeholder convention). * Tags are ASCII-uppercased. * - * Build: see build.sh next to this file. Pinned lexbor version recorded - * there and in the fuzzer README. + * Build: see build.sh next to this file. The script builds upstream lexbor + * master and prints the exact commit used. */ #include diff --git a/tools/css-selector-fuzz/lib/LexborOracle.php b/tools/css-selector-fuzz/lib/LexborOracle.php index a7a6864aa62ab..2dd74c9e92e54 100644 --- a/tools/css-selector-fuzz/lib/LexborOracle.php +++ b/tools/css-selector-fuzz/lib/LexborOracle.php @@ -19,7 +19,7 @@ * ASCII case-insensitively even in no-quirks mode ( attribute selectors * like [id=x] are correctly case-sensitive ). Detected by probe at startup; * when present, lexbor is compared against the reference matcher run with - * quirks-style class/ID folding. Open at the pinned v3.0.0. + * quirks-style class/ID folding. */ class LexborOracle { @@ -62,7 +62,7 @@ public static function available(): bool { return true; } - /** Whether the pinned lexbor exhibits issue #368 ( class/ID case folding ). */ + /** Whether the built lexbor exhibits issue #368 ( class/ID case folding ). */ public static function has_issue_368(): bool { return self::$issue368; } From 9d1129c2135266461280ae4d113edcf6bcc42779 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 12 Jun 2026 08:42:56 +0200 Subject: [PATCH 186/336] CSS selector fuzz: harden fuzzer follow-ups --- tools/css-selector-fuzz/FINDINGS.md | 7 +- tools/css-selector-fuzz/NEXT-STEPS.md | 43 +++---- tools/css-selector-fuzz/README.md | 44 ++++--- .../lib/DocumentGenerator.php | 52 +++++++- tools/css-selector-fuzz/lib/LexborOracle.php | 32 ++++- .../lib/ReferenceMatcher.php | 20 +-- .../lib/SelectorGenerator.php | 5 +- .../lib/WildDocumentGenerator.php | 27 +++- tools/css-selector-fuzz/lib/Worker.php | 65 ++++++++-- tools/css-selector-fuzz/runner.php | 59 ++++++++- tools/css-selector-fuzz/tests/self-check.php | 119 ++++++++++++++++-- 11 files changed, 369 insertions(+), 104 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index ca408ad84c591..e58b256962364 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -17,9 +17,10 @@ completely clean, and the lexbor differential (third independent oracle) agreed with the reference matcher on every compared no-quirks case (0 `lexbor-divergence`). Caveats on the strength of that agreement: roughly half of the `compared` cases (and ~62% of all match assertions across buckets) are vacuous `[] == []`; -quirks-mode class/ID matching is excluded from the differential (lexbor #368) -and so rests on `ReferenceMatcher` alone. See `README.md` for the full -disclosure. +older lexbor builds with #368 exclude quirks-mode class/ID matching from the +differential, though current harnesses include it when the startup probe reports +reliable class/#id behavior in both no-quirks and quirks mode. See `README.md` +for the full disclosure. Reproduce any case: `php tools/css-selector-fuzz/replay.php --selector '' [--html '']`. Auto-minimize a failing seed: `php tools/css-selector-fuzz/minimize.php --seed ` diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 36988221a90fe..8746f5493c529 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -14,16 +14,17 @@ > (`CSS selector:` commits `7419a9fef6` / `0cefeb2fc8` / `16d03e2c5f`), each > with PHPUnit regression tests. A post-fix 5000-seed run is clean. > -> **Open follow-up hardening (post-review):** `tests/self-check.php` runs its -> parse-expectation assertions over a fixed seed window (1–400) that, against -> an *unfixed* core, dodges the known core bugs only by seed luck. On this -> branch the bugs are fixed so the collision risk is gone, but the hazard -> returns whenever the tooling runs against a core without the fixes (e.g. -> cherry-picked onto trunk before the fixes land) or when a future unfixed bug -> is found. Decouple self-check from unfixed core bugs — e.g. allowlist known -> signatures in the parse-expectation loop — as a standalone hardening. This is -> worth doing on its own (it makes self-check robust to *any* future generator -> change) and is the prerequisite for randomized class-NUL document injection. +> **Fuzzer-side follow-up hardening implemented (2026-06-12):** +> `tests/self-check.php` now allowlists known core parse-bug signatures in its +> fixed seed-window parse-expectation loop, while unknown mismatches still fail. +> The safe and wild document generators now inject NUL into random class tokens +> and expose the decoded U+FFFD token to class-selector generation, without +> leaking raw class values into the generic attribute-value pool. The lexbor +> differential includes quirks documents whenever the startup probe confirms +> class/#id behavior in both no-quirks and quirks mode (local master-built +> harness `3a2d595fe8c50e5076ac79c02b2ded79a777bb52` passes), and `runner.php` +> reports per-bucket/per-target vacuous and non-vacuous match assertion rates +> under `matchStats`. > > **Candidate finding 4 — FIXED:** per CSS Syntax 3 §4.3.8, `\` followed by > EOF is a valid escape (EOF is not a newline), and §4.3.7 says consuming it @@ -175,19 +176,15 @@ > was skipped deliberately: it is near-tautological (it could only catch > a `from_selectors()` bypass, and no public path bypasses it). > -> **Still open from the original follow-up list:** the tooling items in -> this file's hardening notes (self-check decoupling, class-NUL injection, -> vacuous-assertion rate, quirks-mode single-oracle gap). New small item -> from the 2026-06-11 review: `gen_chaos()`'s whole-codepoint `unicode` -> branch is dead code — it compares the alphabet *string* against the key -> `'unicode'` after the value lookup already happened — so the unicode -> alphabet is byte-sliced by the generic fallback instead. That slicing is -> what makes chaos emit invalid UTF-8 organically (~15% of chaos cases), -> so making the branch live is a behavior decision, not just a cleanup: -> it would remove chaos's organic ill-formed-byte production, leaving the -> deliberate paths (`invalid-utf8` bucket, `mutated` splice) plus -> `mutated`'s residual organic corruption of pool multibyte characters -> (~2% of mutated cases even without the splice). +> **Still open:** `gen_chaos()`'s whole-codepoint `unicode` branch is dead +> code — it compares the alphabet *string* against the key `'unicode'` after +> the value lookup already happened — so the unicode alphabet is byte-sliced +> by the generic fallback instead. That slicing is what makes chaos emit +> invalid UTF-8 organically (~15% of chaos cases), so making the branch live +> is a behavior decision, not just a cleanup: it would remove chaos's organic +> ill-formed-byte production, leaving the deliberate paths (`invalid-utf8` +> bucket, `mutated` splice) plus `mutated`'s residual organic corruption of +> pool multibyte characters (~2% of mutated cases even without the splice). Repo: `/Users/jonsurrell/a8c/wordpress-develop/html-css-fuzz`, branch `html-css-fuzz` (trunk + merged `html-api/add-css-selector-parser`). diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index cfcfda666cf7e..655d52293cc29 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -45,6 +45,9 @@ produces the same document, the same selector, and the same verdict. path-directed generation is that the *combinator/breadcrumb* walker — the part most likely to harbor a matching bug — is now exercised with real depth, not that every assertion is non-vacuous. + `runner.php` persists per-bucket/per-target match assertion counts and + vacuous/non-vacuous rates under `matchStats` in `state.json`, so this + distribution is reported on every run instead of relying on stale notes. - `unsupported` — valid CSS the API intentionally rejects (pseudo-classes and -elements, `+`/`~`/`||` combinators, namespaces, non-type context selectors); must not parse. @@ -88,15 +91,16 @@ produces the same document, the same selector, and the same verdict. Skipped for ASTs containing invalid UTF-8 (reachable only from chaos/mutated inputs), which the renderer cannot round-trip. - lexbor differential (third, independent oracle; requires the harness — - see below): on no-quirks documents whose selector parsed, a canonical + see below): on full-document cases whose selector parsed, a canonical re-render of the verified AST is matched by liblexbor and compared, - as a multiset of fids, against the reference matcher. Gated on WP and + as a multiset of fids, against the reference matcher. Quirks documents + participate only when the startup probe confirms lexbor's class/#id + folding behavior in both no-quirks and quirks mode. Gated on WP and lexbor building the same element tree (fid/tag/ancestry), so it tests - the selector layer, not tree construction. Verdicts: `lexbor-divergence` - (lexbor ≠ reference) is a fuzzer-oracle problem; `match-mismatch-html` - with no accompanying divergence means reference == lexbor ≠ WP — a - high-confidence WP finding. (Roughly half of `compared` cases are - themselves non-vacuous; the rest assert `[] == []` on both engines.) + the selector layer, not tree construction. Verdicts: + `lexbor-divergence` (lexbor ≠ reference) is a fuzzer-oracle problem; + `match-mismatch-html` with no accompanying divergence means reference + == lexbor ≠ WP — a high-confidence WP finding. - Repeating a case yields a byte-identical result digest (determinism). Note the digest covers the WP-under-test surface (selector, html, parse-nullness, ASTs, failure invariants) but **not** the lexbor @@ -112,7 +116,8 @@ and reports per-batch tallies, persisted to `state.json` under `lexbor`: - `compared` — the differential ran and matched fid-multisets. - `tree-gated` — WP and lexbor built different trees; differential skipped. -- `skipped-quirks` / `skipped-utf8` — quirks document / non-UTF-8 AST. +- `skipped-quirks` / `skipped-utf8` — quirks document while lexbor class/#id + case behavior is not trusted / non-UTF-8 AST. - `n/a` — the differential does not apply (unparseable selector, fragment, no captured tree). - `unavailable` / `error` — the harness was missing or died. The runner prints @@ -127,14 +132,9 @@ Known lexbor issues compensated for when present: case-sensitive). Detected by a startup probe; when present, lexbor is compared against the reference matcher run with quirks-style class/ID folding, and quirks-mode documents are excluded from the differential - entirely. **Consequence — a real coverage hole:** quirks-mode class/ID - matching has no independent third oracle. `ReferenceMatcher` is the sole - authority there, and it encodes the same "ASCII-only case fold in quirks" - reading WP does (both fold via ASCII-only lowercasing), so if that reading - is wrong they would be wrong identically and lexbor — the one engine that - could disagree — is excluded. This is inherent to lexbor #368 being open; - it is the weakest-covered behavior in the suite and is called out here - rather than papered over. + entirely. The same startup probe also checks class and `#id` selectors in + quirks mode; only when all four probes pass is quirks-mode class/ID matching + included in the differential. - lexbor rejects uppercase `I`/`S` attribute-selector modifiers, and its non-ASCII ident-codepoint table omits U+00B7 and U+00C0–U+00F6 (it starts at U+00F8), rejecting e.g. `.Über` while accepting `.über`. @@ -159,13 +159,11 @@ The match oracle's independence differs between class and attribute selectors: splits on ASCII whitespace and folds NUL → U+FFFD per token; `ReferenceMatcher::class_matches()` reimplements that independently (and is pinned against `class_list()` on NUL/FF boundary inputs by `self-check.php`). - The random document generators do **not** emit control bytes inside class - values, so the *randomized* fuzzing never exercises this boundary — it is - covered only by the deterministic self-check cases. Randomized document-side - injection is deliberately deferred: adding it to the hot path perturbs the - deterministic self-check seed space enough to surface the known Bug 3, which - would first require decoupling `self-check.php` from the unfixed core bugs. - A worthwhile, scoped future improvement. + The safe and wild random document generators now inject NUL into class + tokens occasionally and expose the decoded U+FFFD token to class-selector + generation. Raw class attribute values are intentionally kept out of the + generic `attrValues` pool so attribute-selector generation does not inherit + class-list-only decoding semantics. - **Attribute values are matched through a single shared read.** Both WP's attribute matcher and `ReferenceMatcher::attr_matches()` read the same `get_attribute()` output, so a value-decoding bug there would be shared and diff --git a/tools/css-selector-fuzz/lib/DocumentGenerator.php b/tools/css-selector-fuzz/lib/DocumentGenerator.php index 20e6e99607421..20da2825b6ed2 100644 --- a/tools/css-selector-fuzz/lib/DocumentGenerator.php +++ b/tools/css-selector-fuzz/lib/DocumentGenerator.php @@ -314,7 +314,7 @@ private function random_attrs(): array { } $this->pools['attrNames'][] = ascii_strtolower( $name ); - if ( is_string( $value ) ) { + if ( is_string( $value ) && 'class' !== $lower ) { $this->pools['attrValues'][] = $value; } @@ -328,9 +328,12 @@ private function random_class_value(): string { $count = $this->prng->int( 1, 4 ); $classes = array(); for ( $i = 0; $i < $count; $i++ ) { - $class = $this->random_word( true ); - $classes[] = $class; - $this->pools['classes'][] = $class; + $class = $this->random_word( true ); + $raw_class = $this->maybe_inject_class_nul( $class ); + $classes[] = $raw_class; + foreach ( self::class_tokens( $raw_class ) as $token ) { + $this->pools['classes'][] = $token; + } } $ws = array( ' ', ' ', ' ', "\t", "\n", "\f", ' ' ); @@ -347,6 +350,23 @@ private function random_class_value(): string { return $value; } + private function maybe_inject_class_nul( string $class ): string { + if ( '' === $class || ! $this->prng->chance( 12 ) ) { + return $class; + } + + $points = utf8_codepoints( $class ); + $at = $this->prng->int( 0, count( $points ) ); + $out = ''; + foreach ( $points as $i => $point ) { + if ( $i === $at ) { + $out .= "\0"; + } + $out .= $point[0]; + } + return $at === count( $points ) ? $out . "\0" : $out; + } + private function random_id_value(): string { $id = $this->random_word( true ); $this->pools['ids'][] = $id; @@ -581,4 +601,28 @@ public static function get_attribute_value( array $element, string $name ) { } return null; } + + /** + * Class tokens as seen by selector matching: ASCII whitespace separates + * tokens, and NUL inside a token is exposed as U+FFFD by class_list(). + * + * @return string[] + */ + public static function class_tokens( string $class_value ): array { + $tokens = array(); + $length = strlen( $class_value ); + $at = 0; + $ws = " \t\r\n\f"; + while ( $at < $length ) { + $at += strspn( $class_value, $ws, $at ); + if ( $at >= $length ) { + break; + } + + $token_length = strcspn( $class_value, $ws, $at ); + $tokens[] = str_replace( "\0", "\u{FFFD}", substr( $class_value, $at, $token_length ) ); + $at += $token_length; + } + return $tokens; + } } diff --git a/tools/css-selector-fuzz/lib/LexborOracle.php b/tools/css-selector-fuzz/lib/LexborOracle.php index 2dd74c9e92e54..afb508ddc1278 100644 --- a/tools/css-selector-fuzz/lib/LexborOracle.php +++ b/tools/css-selector-fuzz/lib/LexborOracle.php @@ -19,7 +19,8 @@ * ASCII case-insensitively even in no-quirks mode ( attribute selectors * like [id=x] are correctly case-sensitive ). Detected by probe at startup; * when present, lexbor is compared against the reference matcher run with - * quirks-style class/ID folding. + * quirks-style class/ID folding. Quirks documents are compared only when + * the probe also confirms class and #id selectors fold in quirks mode. */ class LexborOracle { @@ -33,6 +34,8 @@ class LexborOracle { private static $available = null; /** @var bool */ private static $issue368 = false; + /** @var bool */ + private static $quirks_class_id_reliable = false; public static function harness_path(): string { return dirname( __DIR__ ) . '/lexbor/harness'; @@ -49,15 +52,31 @@ public static function available(): bool { return false; } - // Probe: sanity plus issue-#368 detection. + // Probe: sanity plus class/#id case-sensitivity behavior. $sane = self::query( '
', 'div.a' ); if ( null === $sane || array( 'x' ) !== $sane['matches'] ) { self::stop(); return false; } - $folded = self::query( '
', '.A' ); - self::$issue368 = null !== $folded && array( 'x' ) === $folded['matches']; + $no_quirks_class = self::query( '
', '.A' ); + $no_quirks_id = self::query( '
', '#A' ); + $quirks_class = self::query( '
', '.A' ); + $quirks_id = self::query( '
', '#A' ); + foreach ( array( $no_quirks_class, $no_quirks_id, $quirks_class, $quirks_id ) as $probe ) { + if ( null === $probe || null !== $probe['error'] ) { + self::stop(); + return false; + } + } + + self::$issue368 = array( 'x' ) === $no_quirks_class['matches'] + || array( 'x' ) === $no_quirks_id['matches']; + self::$quirks_class_id_reliable = ! self::$issue368 + && array() === $no_quirks_class['matches'] + && array() === $no_quirks_id['matches'] + && array( 'x' ) === $quirks_class['matches'] + && array( 'x' ) === $quirks_id['matches']; self::$available = true; return true; } @@ -67,6 +86,11 @@ public static function has_issue_368(): bool { return self::$issue368; } + /** Whether lexbor can be trusted on quirks class/#id case folding. */ + public static function quirks_class_id_reliable(): bool { + return self::$quirks_class_id_reliable; + } + /** * Runs one case through lexbor. * diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php index e86aa4e2e990b..ea422078e9893 100644 --- a/tools/css-selector-fuzz/lib/ReferenceMatcher.php +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -223,25 +223,7 @@ private static function class_matches( string $wanted, array $row, bool $quirks return false; } - $length = strlen( $class_value ); - $at = 0; - while ( $at < $length ) { - $at += strspn( $class_value, self::WHITESPACE, $at ); - if ( $at >= $length ) { - break; - } - $word_length = strcspn( $class_value, self::WHITESPACE, $at ); - $word = substr( $class_value, $at, $word_length ); - $at += $word_length; - - /* - * WP_HTML_Tag_Processor::class_list() replaces NUL with U+FFFD in - * each class token before comparison; model that so a class value - * containing a raw NUL matches a `\0`-escaped ( U+FFFD ) selector - * the same way select() does. - */ - $word = str_replace( "\0", "\u{FFFD}", $word ); - + foreach ( DocumentGenerator::class_tokens( $class_value ) as $word ) { if ( $quirks ? ascii_strtolower( $word ) === ascii_strtolower( $wanted ) diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index fb93f8d66216d..668ad57bcace2 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -957,7 +957,7 @@ private function path_compound_for( array $element ): array { $class_value = DocumentGenerator::get_attribute_value( $element, 'class' ); if ( is_string( $class_value ) ) { - foreach ( preg_split( '/[ \t\n\f\r]+/', $class_value, -1, PREG_SPLIT_NO_EMPTY ) as $word ) { + foreach ( DocumentGenerator::class_tokens( $class_value ) as $word ) { $features[] = array( 'kind' => 'class', 'name' => $word ); } } @@ -974,6 +974,9 @@ private function path_compound_for( array $element ): array { continue; } $seen_attrs[ $lower ] = true; + if ( 'class' === $lower && is_string( $attr[1] ) && false !== strpos( $attr[1], "\0" ) ) { + continue; + } $features[] = $this->path_attr_feature( $lower, $attr[1], 'html' === ( $element['namespace'] ?? 'html' ) ); } diff --git a/tools/css-selector-fuzz/lib/WildDocumentGenerator.php b/tools/css-selector-fuzz/lib/WildDocumentGenerator.php index 78ae5a329f162..3cd7dada17cc6 100644 --- a/tools/css-selector-fuzz/lib/WildDocumentGenerator.php +++ b/tools/css-selector-fuzz/lib/WildDocumentGenerator.php @@ -299,9 +299,11 @@ private function random_attrs(): array { $words = array(); $n = $this->prng->int( 1, 3 ); for ( $j = 0; $j < $n; $j++ ) { - $word = $this->random_word(); - $words[] = $word; - $this->pools['classes'][] = $word; + $word = $this->maybe_inject_class_nul( $this->random_word() ); + $words[] = $word; + foreach ( DocumentGenerator::class_tokens( $word ) as $token ) { + $this->pools['classes'][] = $token; + } } $value = implode( ' ', $words ); } elseif ( 'id' === $lower ) { @@ -317,7 +319,7 @@ private function random_attrs(): array { } $this->pools['attrNames'][] = $lower; - if ( is_string( $value ) ) { + if ( is_string( $value ) && 'class' !== $lower ) { $this->pools['attrValues'][] = $value; } $attrs[] = array( $name, $value ); @@ -326,6 +328,23 @@ private function random_attrs(): array { return $attrs; } + private function maybe_inject_class_nul( string $class ): string { + if ( '' === $class || ! $this->prng->chance( 12 ) ) { + return $class; + } + + $points = utf8_codepoints( $class ); + $at = $this->prng->int( 0, count( $points ) ); + $out = ''; + foreach ( $points as $i => $point ) { + if ( $i === $at ) { + $out .= "\0"; + } + $out .= $point[0]; + } + return $at === count( $points ) ? $out . "\0" : $out; + } + private function render_attrs( array $attrs ): string { $out = ''; foreach ( $attrs as $attr ) { diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index 86ad865d50e37..f701d87974657 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -62,6 +62,8 @@ class Worker { * failures: array, * selector: string, * html: string, + * lexbor: string, + * matchStats: array, * } */ public static function run_case( int $seed ): array { @@ -78,6 +80,7 @@ public static function run_case( int $seed ): array { 'detail' => $detail, ); }; + $match_stats = array(); /* * The processor's own parse is the matching oracle's ground truth. @@ -301,6 +304,9 @@ static function () use ( $complex_list ) { } $html_matches = self::check_select_matches( 'html', $selector_string, $document, $expected, $record ); + if ( null !== $html_matches ) { + self::note_match_assertion( $match_stats, 'html', $expected, $html_matches ); + } // lexbor parses full documents only; fragments skip it. if ( ! ( $document['fragment'] ?? false ) ) { @@ -312,7 +318,10 @@ static function () use ( $complex_list ) { if ( null !== $compound_ast && null !== $tag_rows ) { $expected = ReferenceMatcher::expected_tag_matches_rows( $compound_ast, $tag_rows ); - self::check_select_matches( 'tag', $selector_string, $document, $expected, $record ); + $tag_matches = self::check_select_matches( 'tag', $selector_string, $document, $expected, $record ); + if ( null !== $tag_matches ) { + self::note_match_assertion( $match_stats, 'tag', $expected, $tag_matches ); + } } elseif ( null === $compound_list && null === $compound_error ) { self::check_select_rejection( 'tag', $selector_string, $document, $record ); } @@ -359,6 +368,7 @@ static function ( $failure ) { 'selector' => $selector_string, 'html' => $document['html'], 'lexbor' => $lexbor_state, + 'matchStats' => $match_stats, ); } @@ -768,14 +778,43 @@ private static function check_select_matches( string $target, string $selector_s return $actual; } + private static function note_match_assertion( array &$match_stats, string $target, array $expected, array $actual ): void { + if ( ! isset( $match_stats[ $target ] ) ) { + $match_stats[ $target ] = array( + 'assertions' => 0, + 'nonVacuous' => 0, + ); + } + + ++$match_stats[ $target ]['assertions']; + if ( array() !== $expected || array() !== $actual ) { + ++$match_stats[ $target ]['nonVacuous']; + } + } + + private static function finalize_match_stats( array $match_stats ): array { + foreach ( $match_stats as $bucket => $targets ) { + foreach ( $targets as $target => $counts ) { + $assertions = (int) ( $counts['assertions'] ?? 0 ); + $non_vacuous = (int) ( $counts['nonVacuous'] ?? 0 ); + $vacuous = max( 0, $assertions - $non_vacuous ); + + $match_stats[ $bucket ][ $target ]['vacuous'] = $vacuous; + $match_stats[ $bucket ][ $target ]['nonVacuousRate'] = $assertions > 0 ? round( $non_vacuous / $assertions, 4 ) : 0.0; + $match_stats[ $bucket ][ $target ]['vacuousRate'] = $assertions > 0 ? round( $vacuous / $assertions, 4 ) : 0.0; + } + } + return $match_stats; + } + /** * Runs the lexbor differential — the THIRD, independent matching opinion. * - * Quirks-mode documents are excluded ( lexbor #368 makes its quirks - * behavior untrustworthy and WP's quirks class/ID folding is owned by - * ReferenceMatcher ). The comparison only runs when lexbor built the - * same element tree as WP ( fid/tag/ancestry multiset ), so it tests - * the selector layer, not tree construction. + * Quirks-mode documents are excluded unless the startup probe confirms + * lexbor has reliable class/#id case folding in both no-quirks and quirks + * mode. The comparison only runs when lexbor built the same element tree + * as WP ( fid/tag/ancestry multiset ), so it tests the selector layer, + * not tree construction. * * Verdict triage: * - 'lexbor-divergence' lexbor != reference: a fuzzer-oracle problem @@ -793,7 +832,7 @@ private static function check_lexbor_differential( array $complex_ast, string $s if ( ! LexborOracle::available() ) { return 'unavailable'; } - if ( $quirks ) { + if ( $quirks && ! LexborOracle::quirks_class_id_reliable() ) { return 'skipped-quirks'; } @@ -1115,6 +1154,7 @@ public static function run_batch( array $options ): array { $buckets = array(); $signatures = array(); $lexbor = array(); + $match_stats = array(); $last_seed = null; $stop_reason = 'completed'; @@ -1145,6 +1185,16 @@ public static function run_batch( array $options ): array { $buckets[ $result['bucket'] ] = ( $buckets[ $result['bucket'] ] ?? 0 ) + 1; $lexbor[ $result['lexbor'] ] = ( $lexbor[ $result['lexbor'] ] ?? 0 ) + 1; $last_seed = $seed; + foreach ( $result['matchStats'] as $target => $stats ) { + if ( ! isset( $match_stats[ $result['bucket'] ][ $target ] ) ) { + $match_stats[ $result['bucket'] ][ $target ] = array( + 'assertions' => 0, + 'nonVacuous' => 0, + ); + } + $match_stats[ $result['bucket'] ][ $target ]['assertions'] += $stats['assertions']; + $match_stats[ $result['bucket'] ][ $target ]['nonVacuous'] += $stats['nonVacuous']; + } foreach ( $result['failures'] as $failure ) { ++$failures; @@ -1179,6 +1229,7 @@ public static function run_batch( array $options ): array { 'buckets' => $buckets, 'signatures' => $signatures, 'lexbor' => $lexbor, + 'matchStats' => self::finalize_match_stats( $match_stats ), 'stopReason' => $stop_reason, 'durationMs' => (int) round( 1000 * ( microtime( true ) - $started_at ) ), ); diff --git a/tools/css-selector-fuzz/runner.php b/tools/css-selector-fuzz/runner.php index fc3db262b282a..414eb167a660e 100644 --- a/tools/css-selector-fuzz/runner.php +++ b/tools/css-selector-fuzz/runner.php @@ -114,6 +114,48 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { return null; } +/** Merges per-bucket/per-target match assertion counts. */ +function css_selector_fuzz_merge_match_stats( array &$target, array $source ): void { + foreach ( $source as $bucket => $targets ) { + foreach ( $targets as $match_target => $stats ) { + if ( ! isset( $target[ $bucket ][ $match_target ] ) ) { + $target[ $bucket ][ $match_target ] = array( + 'assertions' => 0, + 'nonVacuous' => 0, + ); + } + $target[ $bucket ][ $match_target ]['assertions'] += (int) ( $stats['assertions'] ?? 0 ); + $target[ $bucket ][ $match_target ]['nonVacuous'] += (int) ( $stats['nonVacuous'] ?? 0 ); + } + } +} + +/** Adds derived rates after all count aggregation is finished. */ +function css_selector_fuzz_finalize_match_stats( array $stats ): array { + foreach ( $stats as $bucket => $targets ) { + foreach ( $targets as $match_target => $counts ) { + $assertions = (int) ( $counts['assertions'] ?? 0 ); + $non_vacuous = (int) ( $counts['nonVacuous'] ?? 0 ); + $vacuous = max( 0, $assertions - $non_vacuous ); + + $stats[ $bucket ][ $match_target ]['vacuous'] = $vacuous; + $stats[ $bucket ][ $match_target ]['nonVacuousRate'] = $assertions > 0 ? round( $non_vacuous / $assertions, 4 ) : 0.0; + $stats[ $bucket ][ $match_target ]['vacuousRate'] = $assertions > 0 ? round( $vacuous / $assertions, 4 ) : 0.0; + } + } + return $stats; +} + +function css_selector_fuzz_write_state( string $state_path, array $state ): void { + $state['matchStats'] = css_selector_fuzz_finalize_match_stats( $state['matchStats'] ?? array() ); + write_json_file( $state_path, $state ); +} + +function css_selector_fuzz_state_for_output( array $state ): array { + $state['matchStats'] = css_selector_fuzz_finalize_match_stats( $state['matchStats'] ?? array() ); + return $state; +} + $options = parse_cli_options( $argv ); if ( option_bool( $options, 'help', false ) || option_bool( $options, 'h', false ) ) { echo "Usage: php tools/css-selector-fuzz/runner.php [--start-seed N] [--max-seeds N] [--duration-seconds N] [--chunk-size N] [--timeout-ms N] [--output-dir DIR] [--stop-on-failure]\n"; @@ -159,10 +201,11 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { 'buckets' => array(), 'signatures' => array(), 'lexbor' => array(), + 'matchStats' => array(), 'nextSeed' => $start_seed, 'stopReason' => null, ); -write_json_file( $state_path, $state ); +css_selector_fuzz_write_state( $state_path, $state ); $deadline = $duration_seconds > 0 ? microtime( true ) + $duration_seconds : null; $seed = $start_seed; @@ -231,9 +274,16 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { } else { ++$state['casesCompleted']; $state['failures'] += $single_summary['failures']; + foreach ( $single_summary['buckets'] as $bucket => $bucket_count ) { + $state['buckets'][ $bucket ] = ( $state['buckets'][ $bucket ] ?? 0 ) + $bucket_count; + } foreach ( $single_summary['signatures'] as $signature => $signature_count ) { $state['signatures'][ $signature ] = ( $state['signatures'][ $signature ] ?? 0 ) + $signature_count; } + foreach ( $single_summary['lexbor'] ?? array() as $lexbor_state => $lexbor_count ) { + $state['lexbor'][ $lexbor_state ] = ( $state['lexbor'][ $lexbor_state ] ?? 0 ) + $lexbor_count; + } + css_selector_fuzz_merge_match_stats( $state['matchStats'], $single_summary['matchStats'] ?? array() ); } } } else { @@ -248,12 +298,13 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { foreach ( $summary['lexbor'] ?? array() as $lexbor_state => $lexbor_count ) { $state['lexbor'][ $lexbor_state ] = ( $state['lexbor'][ $lexbor_state ] ?? 0 ) + $lexbor_count; } + css_selector_fuzz_merge_match_stats( $state['matchStats'], $summary['matchStats'] ?? array() ); } $seed += $count; $state['nextSeed'] = $seed; $state['updatedAt'] = gmdate( 'c' ); - write_json_file( $state_path, $state ); + css_selector_fuzz_write_state( $state_path, $state ); if ( $stop_on_failure && $state['failures'] > 0 ) { $state['stopReason'] = 'stop-on-failure'; @@ -265,7 +316,7 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { $state['stopReason'] = 'max-seeds'; } $state['updatedAt'] = gmdate( 'c' ); -write_json_file( $state_path, $state ); +css_selector_fuzz_write_state( $state_path, $state ); /* * The lexbor differential is the third oracle. If it ever ran ( 'compared' ) @@ -282,5 +333,5 @@ function css_selector_fuzz_worker_summary( string $stdout ): ?array { fwrite( STDERR, "NOTE: lexbor third oracle never ran (harness not built?); run `sh tools/css-selector-fuzz/lexbor/build.sh` for the differential.\n" ); } -echo json_encode_safe( $state ) . "\n"; +echo json_encode_safe( css_selector_fuzz_state_for_output( $state ) ) . "\n"; exit( 0 === $state['failures'] ? 0 : 2 ); diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index 8d9df7e378b70..9664367f1e300 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -14,6 +14,7 @@ use CssSelectorFuzz\DocumentGenerator; use CssSelectorFuzz\Prng; use CssSelectorFuzz\SelectorGenerator; +use CssSelectorFuzz\WildDocumentGenerator; use CssSelectorFuzz\Worker; use function CssSelectorFuzz\utf8_codepoints; @@ -28,6 +29,52 @@ function check( bool $condition, string $message ): void { fwrite( STDERR, "FAIL: {$message}\n" ); } +function known_core_parse_mismatch( string $selector, bool $expected, bool $actual ): ?string { + if ( $expected === $actual || ! $expected || $actual ) { + return null; + } + + if ( ! wp_is_valid_utf8( $selector ) ) { + return 'invalid-utf8-input-scrub'; + } + if ( str_ends_with( $selector, '\\' ) ) { + return 'backslash-at-eof-escape'; + } + if ( substr_count( $selector, '[' ) > substr_count( $selector, ']' ) ) { + return 'eof-auto-closes-attribute-selector'; + } + if ( preg_match( '/\\[[^\\]]*=\\s*[-_a-zA-Z0-9]\\]$/', $selector ) ) { + return 'single-char-unquoted-attribute-value-at-eof'; + } + if ( has_identity_escape_after_multibyte( $selector ) ) { + return 'identity-escape-after-multibyte'; + } + + return null; +} + +function has_identity_escape_after_multibyte( string $selector ): bool { + $seen_multibyte = false; + $length = strlen( $selector ); + for ( $i = 0; $i < $length; $i++ ) { + $byte = ord( $selector[ $i ] ); + if ( $byte > 0x7F ) { + $seen_multibyte = true; + continue; + } + if ( ! $seen_multibyte || '\\' !== $selector[ $i ] || $i + 1 >= $length ) { + continue; + } + + $next = $selector[ $i + 1 ]; + if ( "\n" === $next || "\r" === $next || "\f" === $next || ctype_xdigit( $next ) ) { + continue; + } + return true; + } + return false; +} + Bootstrap::load(); // --- Prng determinism and independence ------------------------------------- @@ -65,6 +112,7 @@ function check( bool $condition, string $message ): void { // --- Selector generator expectations over many seeds ----------------------- $by_bucket = array(); +$allowed_parse_mismatches = array(); for ( $seed = 1; $seed <= 400; $seed++ ) { $prng = new Prng( (string) $seed, 'self-check-selector' ); $document = DocumentGenerator::generate( $prng->fork( 'doc' ) ); @@ -76,20 +124,67 @@ function check( bool $condition, string $message ): void { $complex = WP_CSS_Complex_Selector_List::from_selectors( $selector['selector'] ); if ( null !== $selector['expectCompound'] ) { - check( - $selector['expectCompound'] === ( null !== $compound ), - "Seed {$seed} ({$selector['bucket']}): compound parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) - ); + $expected = $selector['expectCompound']; + $actual = null !== $compound; + $known = known_core_parse_mismatch( $selector['selector'], $expected, $actual ); + if ( null !== $known ) { + $allowed_parse_mismatches[ "compound:{$known}" ] = ( $allowed_parse_mismatches[ "compound:{$known}" ] ?? 0 ) + 1; + } else { + check( + $expected === $actual, + "Seed {$seed} ({$selector['bucket']}): compound parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } } if ( null !== $selector['expectComplex'] ) { - check( - $selector['expectComplex'] === ( null !== $complex ), - "Seed {$seed} ({$selector['bucket']}): complex parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) - ); + $expected = $selector['expectComplex']; + $actual = null !== $complex; + $known = known_core_parse_mismatch( $selector['selector'], $expected, $actual ); + if ( null !== $known ) { + $allowed_parse_mismatches[ "complex:{$known}" ] = ( $allowed_parse_mismatches[ "complex:{$known}" ] ?? 0 ) + 1; + } else { + check( + $expected === $actual, + "Seed {$seed} ({$selector['bucket']}): complex parse expectation for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } } } check( count( $by_bucket ) >= 5, 'Bucket variety: saw ' . count( $by_bucket ) . ' buckets.' ); +if ( array() !== $allowed_parse_mismatches ) { + fwrite( STDERR, 'Allowed known core parse bug signatures: ' . \CssSelectorFuzz\json_encode_safe( $allowed_parse_mismatches ) . "\n" ); +} + +// --- Document generator: randomized class NUL injection -------------------- + +$safe_class_nul = 0; +for ( $seed = 1; $seed <= 200; $seed++ ) { + $document = DocumentGenerator::generate( new Prng( (string) $seed, 'self-check-class-nul-safe' ) ); + if ( false !== strpos( $document['html'], "\0" ) ) { + ++$safe_class_nul; + check( false === str_contains( implode( "\n", $document['pools']['attrValues'] ), "\0" ), "Safe document {$seed}: class NUL does not leak into attrValues pool." ); + check( \CssSelectorFuzz\ast_strings_are_utf8( $document['pools']['classes'] ), "Safe document {$seed}: class pool strings stay valid UTF-8." ); + check( in_array( true, array_map( static function ( string $class ): bool { + return false !== strpos( $class, "\u{FFFD}" ); + }, $document['pools']['classes'] ), true ), "Safe document {$seed}: class pool contains decoded U+FFFD token." ); + } +} +check( $safe_class_nul > 0, "Safe document generator emits randomized class NUL values ({$safe_class_nul} of 200)." ); + +$wild_class_nul = 0; +for ( $seed = 1; $seed <= 200; $seed++ ) { + $document = WildDocumentGenerator::generate( new Prng( (string) $seed, 'self-check-class-nul-wild' ) ); + if ( false !== strpos( $document['html'], "\0" ) ) { + ++$wild_class_nul; + check( false === str_contains( implode( "\n", $document['pools']['attrValues'] ), "\0" ), "Wild document {$seed}: class NUL does not leak into attrValues pool." ); + check( \CssSelectorFuzz\ast_strings_are_utf8( $document['pools']['classes'] ), "Wild document {$seed}: class pool strings stay valid UTF-8." ); + check( in_array( true, array_map( static function ( string $class ): bool { + return false !== strpos( $class, "\u{FFFD}" ); + }, $document['pools']['classes'] ), true ), "Wild document {$seed}: class pool contains decoded U+FFFD token." ); + } +} +check( $wild_class_nul > 0, "Wild document generator emits randomized class NUL values ({$wild_class_nul} of 200)." ); // --- Invalid-UTF-8 bucket: post-scrub AST expectations by construction ------ // from_selectors() replaces each maximal subpart of an ill-formed UTF-8 @@ -224,10 +319,10 @@ function select_fids( string $html, string $selector ): array { // --- Class-value decode boundary (ReferenceMatcher vs WP class_list) -------- // WP's class_list() folds NUL -> U+FFFD and treats FF as a separator; the // reference matcher reimplements tokenization independently. Pin both engines -// against each other on these boundary inputs ( exercised deterministically -// here since the random document generator does not emit control bytes in -// class values — see README #10 ). Each case also checks the reference matcher -// agrees with select() over a TreeCapture of the same markup. +// against each other on these boundary inputs; randomized generator sampling +// above verifies that the same NUL boundary is present in the hot path. Each +// case also checks the reference matcher agrees with select() over a +// TreeCapture of the same markup. function ref_fids( string $html, string $selector ): array { $capture = \CssSelectorFuzz\TreeCapture::capture( $html ); From 202905aec712af214c224988b5cca6154f61a42d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 12 Jun 2026 21:18:51 +0200 Subject: [PATCH 187/336] CSS selector fuzz: Update moved selector-fix references --- tools/css-selector-fuzz/FINDINGS.md | 10 +++++----- tools/css-selector-fuzz/NEXT-STEPS.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/css-selector-fuzz/FINDINGS.md b/tools/css-selector-fuzz/FINDINGS.md index e58b256962364..16ed2d734e79e 100644 --- a/tools/css-selector-fuzz/FINDINGS.md +++ b/tools/css-selector-fuzz/FINDINGS.md @@ -1,12 +1,12 @@ # CSS Selector Fuzzer — Findings -Run: branch `html-css-fuzz` @ `46334f170b`, PHP 8.4.21. 5000 deterministic +Run: branch `html-css-fuzz` @ `5da3afedd0`, PHP 8.4.21. 5000 deterministic seeds, 0 crashes/timeouts. Three distinct, reproduced WordPress-core correctness bugs in the new HTML-API CSS selector support. Every selector below is valid, supported CSS that the API mis-handles **without** reporting lack of support. **Status: all three bugs are fixed on this branch** (commit prefix -`CSS selector:` — Bug 1 `7419a9fef6`, Bug 2 `0cefeb2fc8`, Bug 3 `16d03e2c5f`), +`CSS selector:` — Bug 1 `aed6cfb4aa`, Bug 2 `989e18da8a`, Bug 3 `0a87b20178`), each with PHPUnit regression tests that fail pre-fix. A post-fix 5000-seed run is clean (0 failures, 0 crashes). The repros below no longer trigger; they remain as regression anchors and Trac-ready minimal test cases. @@ -61,7 +61,7 @@ non-hex identity-escape branch is wrong. Depending on what wrong codepoint is produced this also causes spurious parse failures (a valid selector returns `null`). -**Fix (landed in `7419a9fef6`):** read the next codepoint from the byte +**Fix (landed in `aed6cfb4aa`):** read the next codepoint from the byte offset: `mb_substr( substr( $input, $offset ), 0, 1, 'UTF-8' )`. --- @@ -88,7 +88,7 @@ Reproduction against ``: | `[x$=""]` | `I` | none | | `[x~=""]` | none ✅ | none | -**Fix (landed in `0cefeb2fc8`):** in `matches()`, return `false` for `^= $= *=` +**Fix (landed in `989e18da8a`):** in `matches()`, return `false` for `^= $= *=` when `'' === $this->value`, before the `substr_compare`/`strpos` calls. `~=` needs no guard — a whitespace-delimited list never yields an empty item — and a test pins that. (No `substr_compare` length edge exists here: `-strlen('')` @@ -123,7 +123,7 @@ character of the selector string. | `[a^=b]` | parsed ✅ (2-char operator) | | `[a=b].c` | parsed ✅ (trailing content) | -**Fix (landed in `16d03e2c5f`):** change `>=` to `>` (need +**Fix (landed in `0a87b20178`):** change `>=` to `>` (need `strlen - $updated_offset >= 3`). --- diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 8746f5493c529..322d666ad1fe4 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -11,7 +11,7 @@ > which still reproduce. The notes below are retained as the design rationale. > > **Core fixes landed:** the three FINDINGS.md bugs are fixed on this branch -> (`CSS selector:` commits `7419a9fef6` / `0cefeb2fc8` / `16d03e2c5f`), each +> (`CSS selector:` commits `aed6cfb4aa` / `989e18da8a` / `0a87b20178`), each > with PHPUnit regression tests. A post-fix 5000-seed run is clean. > > **Fuzzer-side follow-up hardening implemented (2026-06-12):** From f9e6771d40f46f12eff485cc0e2a79bbc73fdc0a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 12 Jun 2026 22:20:19 +0200 Subject: [PATCH 188/336] CSS Tokenizer: Add CSS token processor --- .../css-api/class-wp-css-builder.php | 241 + .../css-api/class-wp-css-token-processor.php | 1793 ++++++ src/wp-settings.php | 2 + .../phpunit/data/css-api/css-test-cases.json | 4923 +++++++++++++++++ .../tests/css-api/wpCssTokenProcessor.php | 2446 ++++++++ 5 files changed, 9405 insertions(+) create mode 100644 src/wp-includes/css-api/class-wp-css-builder.php create mode 100644 src/wp-includes/css-api/class-wp-css-token-processor.php create mode 100644 tests/phpunit/data/css-api/css-test-cases.json create mode 100644 tests/phpunit/tests/css-api/wpCssTokenProcessor.php diff --git a/src/wp-includes/css-api/class-wp-css-builder.php b/src/wp-includes/css-api/class-wp-css-builder.php new file mode 100644 index 0000000000000..56ae7a5ba009a --- /dev/null +++ b/src/wp-includes/css-api/class-wp-css-builder.php @@ -0,0 +1,241 @@ += 0x80 ) { + $result .= $value[ $i ]; + continue; + } + + // ASCII letters and underscore: always valid in idents. + if ( + ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z + ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z + 0x5F === $byte // _ + ) { + $result .= $value[ $i ]; + continue; + } + + // Hyphen: valid in idents, but check for hyphen-digit at start. + if ( 0x2D === $byte ) { + // Hyphen at position 0 followed by a digit at position 1: escape the digit. + if ( 0 === $i && $i + 1 < $length && ord( $value[ $i + 1 ] ) >= 0x30 && ord( $value[ $i + 1 ] ) <= 0x39 ) { + $result .= '-'; + ++$i; + $result .= sprintf( '\\%X ', ord( $value[ $i ] ) ); + continue; + } + $result .= '-'; + continue; + } + + // Digits: valid except at position 0. + if ( $byte >= 0x30 && $byte <= 0x39 ) { + if ( 0 === $i ) { + $result .= sprintf( '\\%X ', $byte ); + } else { + $result .= $value[ $i ]; + } + continue; + } + + // Everything else: hex-escape. + $result .= sprintf( '\\%X ', $byte ); + } + + return $result; + } + + /** + * Create a quoted CSS string from a plain PHP string value. + * + * Example: + * $value = 'CSS & a ""; + * + * CSS strings are quoted many characters that are problematic in HTML + * or may be complicated for rudimentary CSS or HTML processors to handle + * are encoded using Unicode escape sequences. + * + * @see https://www.w3.org/TR/css-syntax-3/#escaping + */ + public static function string( string $value ): string { + $value = wp_scrub_utf8( $value ); + $escaped = strtr( + $value, + array( + // Escape existing backslashes to prevent unintentional escapes in result. + '\\' => '\\5C ', + + // Pre-processing replaces NULLs and some newlines. Replace and escape as necessary. + "\0" => "\u{FFFD}", + + // Normalize and replace newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing + "\r\n" => '\\A ', + "\r" => '\\A ', + "\f" => '\\A ', + + // Newlines must be escaped in CSS strings. + "\n" => '\\A ', + + // Arbitrary characters for Unicode escaping: + + // HTML syntax may be problematic. + '<' => '\\3C ', + '>' => '\\3E ', + '&' => '\\26 ', + + // CSS syntax may be problematic. + ',' => '\\2C ', + ';' => '\\3B ', + '{' => '\\7B ', + '}' => '\\7D ', + '"' => '\\22 ', + "'" => '\\27 ', + ) + ); + return "\"{$escaped}\""; + } + + public static function normalize_and_escape_css( string $css ): string { + $css = wp_scrub_utf8( $css ); + $processor = WP_CSS_Token_Processor::create( $css ); + if ( null === $processor ) { + return ''; + } + + $normalized_css = ''; + + while ( $processor->next_token() ) { + switch ( $processor->get_token_type() ) { + + // Basic punctuation: + case WP_CSS_Token_Processor::TOKEN_SEMICOLON: $normalized_css .= ';'; break; + case WP_CSS_Token_Processor::TOKEN_COMMA: $normalized_css .= ','; break; + case WP_CSS_Token_Processor::TOKEN_WHITESPACE: $normalized_css .= ' '; break; + case WP_CSS_Token_Processor::TOKEN_COLON: $normalized_css .= ':'; break; + + // Paired punctuation: + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: $normalized_css .= '{'; break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: $normalized_css .= '}'; break; + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: $normalized_css .= '('; break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: $normalized_css .= ')'; break; + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: $normalized_css .= '['; break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: $normalized_css .= ']'; break; + + // "@" + ident + case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: + $normalized_css .= '@' . self::ident( $processor->get_token_value() ); + break; + + // ident + "(" + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + $normalized_css .= self::ident( $processor->get_token_value() ) . '('; + break; + + /* + * Hash tokens are not idents but their value can be escaped as such. + * + * ‖→ "#" →─┐ ┌──────────────────────────────┐ ┌─→‖ + * ├─→─┤ a-z A-Z 0-9 _ - or non-ASCII ├─→─┤ + * │ └──────────────────────────────┘ │ + * │ ┌──────────────────────────────┐ │ + * ├─→─┤ escape ├─→─┤ + * │ └──────────────────────────────┘ │ + * └──────────────────←───────────────────┘ + */ + case WP_CSS_Token_Processor::TOKEN_HASH: + $normalized_css .= '#' . self::ident( $processor->get_token_value() ); + break; + + case WP_CSS_Token_Processor::TOKEN_DIMENSION: + $normalized_css .= $processor->get_token_value() . $processor->get_token_unit(); + break; + + case WP_CSS_Token_Processor::TOKEN_PERCENTAGE: + $normalized_css .= "%{$processor->get_token_value()}"; + break; + + case WP_CSS_Token_Processor::TOKEN_NUMBER: + $normalized_css .= $processor->get_token_value(); + break; + + case WP_CSS_Token_Processor::TOKEN_DELIM: + $normalized_css .= $processor->get_token_value(); + break; + + case WP_CSS_Token_Processor::TOKEN_IDENT: + $normalized_css .= self::ident( $processor->get_token_value() ); + break; + + case WP_CSS_Token_Processor::TOKEN_STRING: + var_dump( $processor->get_token_value() ); + $normalized_css .= self::string( $processor->get_token_value() ); + break; + + // Keep or strip comments? + case WP_CSS_Token_Processor::TOKEN_COMMENT: + $normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() ); + break; + + /** + * A is an open string that reaches a newline. + * + * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * @see https://www.w3.org/TR/css-syntax-3/#preserved-tokens + * > Note: The tokens <}-token>s, <)-token>s, <]-token>, , and are always parse errors, but they are preserved in the token stream by this specification to allow other specs, such as Media Queries, to define more fine-grained error-handling than just dropping an entire declaration or block. + */ + case WP_CSS_Token_Processor::TOKEN_BAD_STRING: + $normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() ) . "\n"; + break; + + case WP_CSS_Token_Processor::TOKEN_URL: + case WP_CSS_Token_Processor::TOKEN_BAD_URL: + case WP_CSS_Token_Processor::TOKEN_CDC: + case WP_CSS_Token_Processor::TOKEN_CDO: + default: + throw new Error( 'unhandled token type ' . $processor->get_token_type() . ' with value ' . var_export( $processor->get_token_value(), true ) ); + } + } + + return strtr( + $normalized_css, + array( + ' ' => '␠', + "\t" => "␉\t", + "\n" => "␊\n", + ) + ); + } +} diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php new file mode 100644 index 0000000000000..f77faaa3f8dc4 --- /dev/null +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -0,0 +1,1793 @@ + Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) + * > code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE + * > FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT + * > CHARACTER (�). + * + * This processor delays normalization as much as possible. That keeps the raw byte + * positions intact for accurate rewrites while still letting consumers ask for a + * normalized token when they need one. + * + * ### No EOF token + * + * The EOF token is a CSS parsing concept, not CSS tokenization concept. Therefore, + * this processor does not produce it. + * + * ### UTF-8 handling + * + * Only UTF-8 strings are supported. Invalid sequences are replaced with U+FFFD (�) + * using the maximal subpart approach described in + * https://www.unicode.org/versions/Unicode9.0.0/ch03.pdf, section 3.9 Best Practices + * for Using U+FFFD. + * + * ## Usage + * + * Basic iteration: + * + * $css = 'width: 10px;'; + * $processor = WP_CSS_Token_Processor::create( $css ); + * while ( $processor->next_token() ) { + * echo $processor->get_normalized_token(); + * } + * // Outputs: + * // width: 10px; + * + * Rewriting a URL while keeping the rest of the stylesheet intact: + * + * $css = 'background: url(old.jpg) center / cover;'; + * $processor = WP_CSS_Token_Processor::create( $css ); + * while ( $processor->next_token() ) { + * if ( WP_CSS_Token_Processor::TOKEN_URL === $processor->get_token_type() ) { + * $processor->set_value( 'uploads/new.jpg' ); + * } + * } + * $result = $processor->get_updated_css(); + * // background: url(uploads/new.jpg) center / cover; + * + * Gathering diagnostics with byte offsets: + * + * $css = "color: red;\ncolor: re\nd;"; + * $processor = WP_CSS_Token_Processor::create( $css ); + * $bad_strings = array(); + * while ( $processor->next_token() ) { + * if ( WP_CSS_Token_Processor::TOKEN_BAD_STRING === $processor->get_token_type() ) { + * $bad_strings[] = array( + * 'start' => $processor->get_token_start(), + * 'length' => $processor->get_token_length(), + * 'value' => $processor->get_unnormalized_token(), + * ); + * } + * } + * + * @see https://www.w3.org/TR/css-syntax-3/#tokenization + */ +class WP_CSS_Token_Processor { + /** + * Token type constants matching the CSS Syntax Level 3 specification. + * + * @see https://www.w3.org/TR/css-syntax-3/#tokenization + */ + public const TOKEN_WHITESPACE = 'whitespace-token'; + public const TOKEN_COMMENT = 'comment'; + public const TOKEN_STRING = 'string-token'; + + /** + * BAD-STRING tokens occur when a string contains an unescaped newline. + * + * Valid strings: "hello", 'world', "line1\Aline2" (escaped newline) + * Invalid (produces bad-string): "hello + * world" (literal newline breaks the string) + * + * The processor stops at the newline and produces a bad-string token for error recovery. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token + */ + public const TOKEN_BAD_STRING = 'bad-string-token'; + public const TOKEN_HASH = 'hash-token'; + public const TOKEN_DELIM = 'delim-token'; + public const TOKEN_NUMBER = 'number-token'; + public const TOKEN_PERCENTAGE = 'percentage-token'; + public const TOKEN_DIMENSION = 'dimension-token'; + public const TOKEN_AT_KEYWORD = 'at-keyword-token'; + public const TOKEN_COLON = 'colon-token'; + public const TOKEN_SEMICOLON = 'semicolon-token'; + public const TOKEN_COMMA = 'comma-token'; + public const TOKEN_LEFT_PAREN = '(-token'; + public const TOKEN_RIGHT_PAREN = ')-token'; + public const TOKEN_LEFT_BRACKET = '[-token'; + public const TOKEN_RIGHT_BRACKET = ']-token'; + public const TOKEN_LEFT_BRACE = '{-token'; + public const TOKEN_RIGHT_BRACE = '}-token'; + public const TOKEN_FUNCTION = 'function-token'; + + /** + * URL tokens represent unquoted URLs in url() notation. + * + * For example, `url(image.jpg)` is a URL token. + * + * Quoted URLs like `url( "https://example.com" )` are handled as a function + * token, _not_ a URL token. + * + * Bad URL tokens are created when invalid characters are encountered in + * a URL token. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-url-token + */ + public const TOKEN_URL = 'url-token'; + + /** + * BAD-URL tokens occur when a URL contains invalid characters. + * + * Invalid characters: quotes ("), apostrophes ('), parentheses (() + * Example invalid: url(image(.jpg) or url(image".jpg) + * + * When detected, the processor consumes everything up to ) or EOF. + * This prevents the bad URL from breaking subsequent tokens. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token + */ + public const TOKEN_BAD_URL = 'bad-url-token'; + + /** + * Identifier tokens, such as `color`, `margin-top`, `red`, + * `inherit`, `--my-var`, `\x-escaped`, `über` (Unicode), etc. + * + * There are restrictions on the codepoints that start or are contained in + * an identifier, and identifiers may contain escape sequences. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-ident-token + */ + public const TOKEN_IDENT = 'ident-token'; + + /** + * CDC (Comment Delimiter Close) token: --> + * + * Legacy token from when CSS was embedded in HTML + * + * Modern CSS no longer needs these, but they're preserved for compatibility. + * In stylesheets, they're typically treated like whitespace. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-CDC-token + */ + public const TOKEN_CDC = 'CDC-token'; + + /** + * CDO (Comment Delimiter Open) token: ) + * + * Comment Delimiter Close - legacy HTML comment syntax in CSS. + * + * @see https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram + */ + if ( + $this->at + 2 < $this->length && + '-' === $this->css[ $this->at + 1 ] && + '>' === $this->css[ $this->at + 2 ] + ) { + // Consume them and return a . + $this->at += 3; + $this->token_type = self::TOKEN_CDC; + $this->token_length = 3; + return true; + } + + // Otherwise, if the input stream starts with an ident sequence, + // reconsume the current input code point, consume an ident-like + // token, and return it. + if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) { + return $this->consume_ident_like(); + } + + // Otherwise, return a with its value set to the current input code point. + ++$this->at; + $this->token_type = self::TOKEN_DELIM; + $this->token_length = 1; + return true; + } + + /* + * U+003C LESS-THAN SIGN (<) + * If followed by !--, this is a CDO token (\n", + "tokens": [ + { + "type": "CDC-token", + "raw": "-->", + "startIndex": 0, + "endIndex": 3, + "normalized": "-->", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0001": { + "css": "foo\n", + "tokens": [ + { + "type": "ident-token", + "raw": "foo", + "startIndex": 0, + "endIndex": 3, + "normalized": "foo", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0002": { + "css": "--\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--", + "startIndex": 0, + "endIndex": 2, + "normalized": "--", + "value": "--" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 2, + "endIndex": 3, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0003": { + "css": "--0\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--0", + "startIndex": 0, + "endIndex": 3, + "normalized": "--0", + "value": "--0" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0004": { + "css": "-\\\n", + "tokens": [ + { + "type": "delim-token", + "raw": "-", + "startIndex": 0, + "endIndex": 1, + "normalized": "-", + "value": "-" + }, + { + "type": "delim-token", + "raw": "\\", + "startIndex": 1, + "endIndex": 2, + "normalized": "\\", + "value": "\\" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 2, + "endIndex": 3, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0005": { + "css": "-\\ \n", + "tokens": [ + { + "type": "ident-token", + "raw": "-\\ ", + "startIndex": 0, + "endIndex": 3, + "normalized": "- ", + "value": "- " + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0006": { + "css": "--💅\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--💅", + "startIndex": 0, + "endIndex": 6, + "normalized": "--💅", + "value": "--💅" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 6, + "endIndex": 7, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0007": { + "css": "-§\n", + "tokens": [ + { + "type": "ident-token", + "raw": "-§", + "startIndex": 0, + "endIndex": 3, + "normalized": "-§", + "value": "-§" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0008": { + "css": "-×\n", + "tokens": [ + { + "type": "ident-token", + "raw": "-×", + "startIndex": 0, + "endIndex": 3, + "normalized": "-×", + "value": "-×" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0009": { + "css": "--a𐀀\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--a𐀀", + "startIndex": 0, + "endIndex": 7, + "normalized": "--a𐀀", + "value": "--a𐀀" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 7, + "endIndex": 8, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0001": { + "css": "url(foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "url(foo)", + "startIndex": 0, + "endIndex": 8, + "normalized": "url(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 8, + "endIndex": 9, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0002": { + "css": "\\75 Rl(foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "\\75 Rl(foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "uRl(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0003": { + "css": "uR\\6c (foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "uR\\6c (foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "uRl(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0004": { + "css": "url('foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 4, + "endIndex": 9, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 9, + "endIndex": 10, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 10, + "endIndex": 11, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0005": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 5, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 5, + "endIndex": 10, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 10, + "endIndex": 11, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0006": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 6, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 6, + "endIndex": 11, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 11, + "endIndex": 12, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 12, + "endIndex": 13, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0007": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 7, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 7, + "endIndex": 12, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 12, + "endIndex": 13, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 13, + "endIndex": 14, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0008": { + "css": "not-url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "not-url(", + "startIndex": 0, + "endIndex": 8, + "normalized": "not-url(", + "value": "not-url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 8, + "endIndex": 11, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 11, + "endIndex": 16, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 16, + "endIndex": 17, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 17, + "endIndex": 18, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0009": { + "css": "url( foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "url( foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "url( foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-curly-bracket/0001": { + "css": "{\n", + "tokens": [ + { + "type": "{-token", + "raw": "{", + "startIndex": 0, + "endIndex": 1, + "normalized": "{", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-parenthesis/0001": { + "css": "(\n", + "tokens": [ + { + "type": "(-token", + "raw": "(", + "startIndex": 0, + "endIndex": 1, + "normalized": "(", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-square-bracket/0001": { + "css": "[\n", + "tokens": [ + { + "type": "[-token", + "raw": "[", + "startIndex": 0, + "endIndex": 1, + "normalized": "[", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/less-than/0001": { + "css": "<\n", + "tokens": [ + { + "type": "delim-token", + "raw": "<", + "startIndex": 0, + "endIndex": 1, + "normalized": "<", + "value": "<" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/less-than/0002": { + "css": "` when a raw `--` type selector is + // followed immediately by a child combinator. + if ( '' === $before && '--' === $rendered_type ) { + $before = ' '; + } + $out .= $before . '>' . $this->maybe_ws( 50 ); } else { $out .= $this->ws(); } diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index 9664367f1e300..79c7dd7cfeff4 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -149,6 +149,19 @@ function has_identity_escape_after_multibyte( string $selector ): bool { ); } } + + if ( null !== $selector['ast'] && null !== $complex ) { + check( + $selector['ast'] === \CssSelectorFuzz\AstExtractor::from_complex_list( $complex ), + "Seed {$seed} ({$selector['bucket']}): complex AST round-trips for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } + if ( null !== $selector['ast'] && null !== $compound ) { + check( + $selector['ast'] === \CssSelectorFuzz\AstExtractor::from_compound_list( $compound ), + "Seed {$seed} ({$selector['bucket']}): compound AST round-trips for: " . \CssSelectorFuzz\printable_bytes( $selector['selector'] ) + ); + } } check( count( $by_bucket ) >= 5, 'Bucket variety: saw ' . count( $by_bucket ) . ' buckets.' ); @@ -156,6 +169,113 @@ function has_identity_escape_after_multibyte( string $selector ): bool { fwrite( STDERR, 'Allowed known core parse bug signatures: ' . \CssSelectorFuzz\json_encode_safe( $allowed_parse_mismatches ) . "\n" ); } +// --- Selector renderer token-boundary regressions ------------------------- +// These pin places where adjacent rendered tokens can accidentally form a +// different token stream. In particular, a raw `--` type selector followed by +// a child combinator must not render as `-->`, which CSS tokenization treats +// as CDC. + +$renderer_boundary_asts = array( + 'cdc-child-combinator' => array( + array( + 'context' => array( + array( '--', '>' ), + ), + 'self' => array( + 'type' => 'a', + 'subs' => null, + ), + ), + ), + 'cdc-nested-child-combinator' => array( + array( + 'context' => array( + array( 'b', '>' ), + array( '--', '>' ), + ), + 'self' => array( + 'type' => 'a', + 'subs' => null, + ), + ), + ), + 'cdc-selector-list-branch' => array( + array( + 'context' => array( + array( '--', '>' ), + ), + 'self' => array( + 'type' => 'a', + 'subs' => null, + ), + ), + array( + 'context' => array(), + 'self' => array( + 'type' => '--', + 'subs' => array( + array( + 'kind' => 'class', + 'name' => 'x', + ), + array( + 'kind' => 'id', + 'name' => '--', + ), + ), + ), + ), + ), + 'attribute-ident-modifier-i' => array( + array( + 'context' => array(), + 'self' => array( + 'type' => null, + 'subs' => array( + array( + 'kind' => 'attr', + 'name' => 'x', + 'matcher' => 'exact', + 'value' => 'i', + 'modifier' => 'case-insensitive', + ), + ), + ), + ), + ), + 'attribute-ident-modifier-s' => array( + array( + 'context' => array(), + 'self' => array( + 'type' => null, + 'subs' => array( + array( + 'kind' => 'attr', + 'name' => 'x', + 'matcher' => 'exact', + 'value' => 's', + 'modifier' => 'case-sensitive', + ), + ), + ), + ), + ), +); + +foreach ( $renderer_boundary_asts as $name => $ast ) { + for ( $seed = 1; $seed <= 75; $seed++ ) { + $selector = SelectorGenerator::render( new Prng( (string) $seed, "self-check-renderer-boundary-{$name}" ), $ast ); + $complex = WP_CSS_Complex_Selector_List::from_selectors( $selector ); + check( null !== $complex, "Renderer boundary {$name} seed {$seed}: parse for " . \CssSelectorFuzz\printable_bytes( $selector ) ); + if ( null !== $complex ) { + check( + $ast === \CssSelectorFuzz\AstExtractor::from_complex_list( $complex ), + "Renderer boundary {$name} seed {$seed}: AST round-trips for " . \CssSelectorFuzz\printable_bytes( $selector ) + ); + } + } +} + // --- Document generator: randomized class NUL injection -------------------- $safe_class_nul = 0; From ce5d9bdca47cc0d4a60f2fe97fca5ace3849adbc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 15 Jun 2026 22:51:06 +0200 Subject: [PATCH 197/336] CSS selector fuzzer: send raw selectors to lexbor oracle Lexbor parser bugs were hidden because the differential fed lexbor a canonical AST render. Query lexbor with the selector bytes accepted by WordPress, and mark lexbor parse rejects/divergences as lexbor/fuzzer-oracle noise rather than WP findings. --- tools/css-selector-fuzz/NEXT-STEPS.md | 27 +++++++---- tools/css-selector-fuzz/README.md | 32 ++++++++----- .../lib/SelectorGenerator.php | 8 ++-- tools/css-selector-fuzz/lib/Worker.php | 45 +++++++++---------- 4 files changed, 63 insertions(+), 49 deletions(-) diff --git a/tools/css-selector-fuzz/NEXT-STEPS.md b/tools/css-selector-fuzz/NEXT-STEPS.md index 322d666ad1fe4..f22f2ca21c3b9 100644 --- a/tools/css-selector-fuzz/NEXT-STEPS.md +++ b/tools/css-selector-fuzz/NEXT-STEPS.md @@ -14,6 +14,13 @@ > (`CSS selector:` commits `aed6cfb4aa` / `989e18da8a` / `0a87b20178`), each > with PHPUnit regression tests. A post-fix 5000-seed run is clean. > +> **Current lexbor differential behavior (2026-06-15):** lexbor now receives +> the exact selector bytes accepted by WP, not a canonical re-render of the +> parsed AST. `lexbor-parse-reject` remains classified as lexbor/fuzzer-oracle +> noise, never as a WP finding by itself. Historical notes below that say +> canonical re-rendering sidestepped lexbor parser bugs describe the earlier +> differential behavior. +> > **Fuzzer-side follow-up hardening implemented (2026-06-12):** > `tests/self-check.php` now allowlists known core parse-bug signatures in its > fixed seed-window parse-expectation loop, while unknown mismatches still fail. @@ -58,10 +65,12 @@ > truncations (`[`, `[a=`, `[a~`, `[a=b, div`) stay invalid. Verified > against Chromium form-by-form, including an exhaustive per-byte > truncation table in review. lexbor rejects all EOF-truncated forms -> (drafted as `lexbor/UPSTREAM-ISSUES.md` issue 4); the differential is -> unaffected because it compares canonical re-renders. Fuzzer gained an -> `eof-truncated` edge-escape kind and the invalid corpus was reshuffled -> along the new validity boundary; COVERAGE.md regenerated. +> (drafted as `lexbor/UPSTREAM-ISSUES.md` issue 4). Historical note: the +> differential was unaffected at the time because it compared canonical +> re-renders; current behavior feeds lexbor the original selector bytes, so +> these are expected `lexbor-parse-reject` noise, not WP findings. Fuzzer +> gained an `eof-truncated` edge-escape kind and the invalid corpus was +> reshuffled along the new validity boundary; COVERAGE.md regenerated. > > **Invalid-UTF-8 input policy — IMPLEMENTED as scrub (2026-06-11):** > selector strings are UTF-8 text; `normalize_selector_input()` now decodes @@ -166,10 +175,12 @@ > but NOT per the WHATWG maximal-subpart rule — one U+FFFD per byte for > truncated sequences (`E2 8C` → 2, spec 1) and one per whole sequence for > UTF-8-encoded surrogate halves (`ED A0 80` → 1, spec 3) — drafted as -> `lexbor/UPSTREAM-ISSUES.md` issue 6. The differential is unaffected and -> stays live for the bucket: it feeds lexbor the canonical re-render of -> the post-scrub AST (escaped, pure ASCII), the same mechanism that -> sidesteps lexbor's other byte-level parsing bugs. Doc-side observation: +> `lexbor/UPSTREAM-ISSUES.md` issue 6. Historical note: the differential +> used to stay live for the bucket by feeding lexbor the canonical re-render +> of the post-scrub AST (escaped, pure ASCII), the same mechanism that +> sidestepped lexbor's other byte-level parsing bugs. Current behavior feeds +> lexbor the original selector bytes, so these known decoding differences +> surface as lexbor/fuzzer-oracle noise. Doc-side observation: > lexbor keeps raw invalid bytes in the DOM unchanged (same stance as the > Tag Processor), so raw doc bytes match nothing in either engine. The > handoff's optional metamorphic relation `parse(s) === parse(scrub(s))` diff --git a/tools/css-selector-fuzz/README.md b/tools/css-selector-fuzz/README.md index 6025b6e45a10f..9c094692b456d 100644 --- a/tools/css-selector-fuzz/README.md +++ b/tools/css-selector-fuzz/README.md @@ -91,16 +91,18 @@ produces the same document, the same selector, and the same verdict. Skipped for ASTs containing invalid UTF-8 (reachable only from chaos/mutated inputs), which the renderer cannot round-trip. - lexbor differential (third, independent oracle; requires the harness — - see below): on full-document cases whose selector parsed, a canonical - re-render of the verified AST is matched by liblexbor and compared, - as a multiset of fids, against the reference matcher. Quirks documents + see below): on full-document cases whose selector parsed, the exact + selector bytes accepted by WP are matched by liblexbor and compared, as a + multiset of fids, against the reference matcher. Quirks documents participate only when the startup probe confirms lexbor's class/#id folding behavior in both no-quirks and quirks mode. Gated on WP and lexbor building the same element tree (fid/tag/ancestry), so it tests the selector layer, not tree construction. Verdicts: - `lexbor-divergence` (lexbor ≠ reference) is a fuzzer-oracle problem; - `match-mismatch-html` with no accompanying divergence means reference - == lexbor ≠ WP — a high-confidence WP finding. + `lexbor-parse-reject` and `lexbor-divergence` (lexbor ≠ reference) are + lexbor/fuzzer-oracle problems, with `wpFinding: false` in their details; + `match-mismatch-html` with no accompanying `lexbor-parse-reject` or + `lexbor-divergence` means reference == lexbor ≠ WP — a high-confidence WP + finding. - Repeating a case yields a byte-identical result digest (determinism). Note the digest covers the WP-under-test surface (selector, html, parse-nullness, ASTs, failure invariants) but **not** the lexbor @@ -114,17 +116,19 @@ liblexbor from upstream `master`; the build script prints the exact commit). The worker auto-detects the binary at `tools/css-selector-fuzz/lexbor/harness` and reports per-batch tallies, persisted to `state.json` under `lexbor`: -- `compared` — the differential ran and matched fid-multisets. +- `compared` — the differential ran to a selector verdict; parser rejects are + recorded as `lexbor-parse-reject`, and successful parses either match + fid-multisets or record `lexbor-divergence`. - `tree-gated` — WP and lexbor built different trees; differential skipped. -- `skipped-quirks` / `skipped-utf8` — quirks document while lexbor class/#id - case behavior is not trusted / non-UTF-8 AST. +- `skipped-quirks` — quirks document while lexbor class/#id case behavior is + not trusted. - `n/a` — the differential does not apply (unparseable selector, fragment, no captured tree). - `unavailable` / `error` — the harness was missing or died. The runner prints a loud warning if these appear after the harness had run, so a third oracle that dies mid-run cannot hide behind a green run. -Known lexbor issues compensated for when present: +Known lexbor issues and handling: - [#368](https://github.com/lexbor/lexbor/issues/368): class and `#id` selectors match ASCII case-insensitively even in @@ -138,9 +142,13 @@ Known lexbor issues compensated for when present: - lexbor rejects uppercase `I`/`S` attribute-selector modifiers, and its non-ASCII ident-codepoint table omits U+00B7 and U+00C0–U+00F6 (it starts at U+00F8), rejecting e.g. `.Über` while accepting `.über`. - Both sidestepped by the canonical re-render (lowercase modifiers, all - non-ASCII hex-escaped); both are candidate upstream reports, not WP + These can now surface as `lexbor-parse-reject` because lexbor receives the + original selector input; they are candidate upstream reports, not WP findings. +- lexbor's invalid-UTF-8 selector decoding differs from WP's + maximal-subpart scrub. These cases can surface as parser rejects or + divergences, and remain lexbor/fuzzer-oracle noise unless independently + confirmed against WP. - `lxb_selectors_find` reports a node once per matching selector-list branch; `LXB_SELECTORS_OPT_MATCH_FIRST` dedupes. - lexbor matches `[x~=""]` against whitespace-only attribute values diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 85a2a7871b2b9..90eadaa2f2427 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -90,11 +90,9 @@ public static function render( Prng $prng, array $list_ast, bool $escape_boost = * Renders a canonical complex-list AST deterministically with minimal * escaping: single spaces around combinators, `, ` between branches, * double-quoted attribute values, lowercase `i`/`s` modifiers, and all - * non-ASCII codepoints hex-escaped. Used to hand a semantically-identical - * selector to external engines: lexbor rejects some byte-level forms WP - * correctly accepts ( uppercase I/S attribute modifiers; raw non-ASCII - * ident codepoints in U+00B7, U+00C0-U+00F6 — its non-ASCII ident table - * starts at U+00F8 ). Escaping sidesteps codepoint classification. + * non-ASCII codepoints hex-escaped. Useful when a deterministic + * semantically-identical selector string is needed from an already parsed + * AST. */ public static function render_canonical( array $list_ast ): string { $branches = array(); diff --git a/tools/css-selector-fuzz/lib/Worker.php b/tools/css-selector-fuzz/lib/Worker.php index f701d87974657..961d24c0245fe 100644 --- a/tools/css-selector-fuzz/lib/Worker.php +++ b/tools/css-selector-fuzz/lib/Worker.php @@ -821,12 +821,15 @@ private static function finalize_match_stats( array $match_stats ): array { * ( or an un-compensated lexbor bug ) — never a * WP verdict on its own. * - 'lexbor-parse-reject' lexbor refused a selector WP accepted. - * - match-mismatch-html with NO lexbor-divergence on the same case - * means reference == lexbor != WP: a - * high-confidence WP finding. + * This is lexbor/fuzzer-oracle noise — never a + * WP verdict on its own. + * - match-mismatch-html with NO lexbor-divergence and NO + * lexbor-parse-reject on the same case means + * reference == lexbor != WP: a high-confidence + * WP finding. * * @return string Tally state: - * unavailable|skipped-quirks|skipped-utf8|error|tree-gated|compared. + * unavailable|skipped-quirks|error|tree-gated|compared. */ private static function check_lexbor_differential( array $complex_ast, string $selector_string, array $document, array $rows, bool $quirks, array $expected, callable $record ): string { if ( ! LexborOracle::available() ) { @@ -837,22 +840,11 @@ private static function check_lexbor_differential( array $complex_ast, string $s } /* - * lexbor receives a canonical re-render of the (already verified) - * AST rather than the original byte form: the differential targets - * matching semantics, while byte-level parsing (escapes, whitespace, - * modifier case — lexbor e.g. rejects uppercase I/S modifiers) is - * covered by the AST round-trip and metamorphic invariants. ASTs - * containing invalid UTF-8 cannot be re-rendered; since - * from_selectors() scrubs input to U+FFFD before parsing, none should - * exist and this skip is defensive ( a nonzero skipped-utf8 tally - * indicates a normalization bypass ). + * Feed lexbor the exact selector bytes WP parsed. This intentionally + * keeps parser-level lexbor rejections visible as lexbor/fuzzer-oracle + * noise rather than canonicalizing them away. */ - if ( ! ast_strings_are_utf8( $complex_ast ) ) { - return 'skipped-utf8'; - } - $canonical = SelectorGenerator::render_canonical( $complex_ast ); - - $lex = LexborOracle::query( $document['html'], $canonical ); + $lex = LexborOracle::query( $document['html'], $selector_string ); if ( null === $lex ) { return 'error'; } @@ -861,8 +853,11 @@ private static function check_lexbor_differential( array $complex_ast, string $s $record( 'lexbor-parse-reject', array( - 'note' => 'lexbor rejected the canonical form of a selector the WP parser accepted', - 'canonical' => printable_bytes( $canonical ), + 'classification' => 'lexbor/fuzzer-oracle', + 'wpFinding' => false, + 'lexborError' => $lex['error'], + 'note' => 'lexbor rejected the same selector input that WP accepted', + 'selector' => printable_bytes( $selector_string ), ) ); return 'compared'; @@ -905,9 +900,11 @@ private static function check_lexbor_differential( array $complex_ast, string $s $record( 'lexbor-divergence', array( - 'reference' => $expected_for_lexbor, - 'lexbor' => $lex_matches, - 'issue368' => LexborOracle::has_issue_368(), + 'classification' => 'lexbor/fuzzer-oracle', + 'wpFinding' => false, + 'reference' => $expected_for_lexbor, + 'lexbor' => $lex_matches, + 'issue368' => LexborOracle::has_issue_368(), ) ); } From 1be88a47a5779cb266ca8d2e5aa8aa5d9a8c114d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 17 Jun 2026 13:26:56 +0200 Subject: [PATCH 198/336] HTML API: Preserve escaped type selector asterisks CSS tokenization already distinguishes a literal '*' delimiter from escaped identifiers that decode to '*'. Preserve that distinction in WP_CSS_Type_Selector so only the delimiter path is universal, while escaped asterisks remain ordinary type selectors. --- .../css/class-wp-css-type-selector.php | 21 +++++++--- .../tests/html-api/wpCssTypeSelector.php | 41 +++++++++++++++++++ .../tests/html-api/wpHtmlProcessor-select.php | 7 ++++ .../html-api/wpHtmlTagProcessor-select.php | 7 ++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/css/class-wp-css-type-selector.php b/src/wp-includes/html-api/css/class-wp-css-type-selector.php index fcb92fd34bc25..3746eefa4fbdd 100644 --- a/src/wp-includes/html-api/css/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/css/class-wp-css-type-selector.php @@ -18,19 +18,28 @@ */ final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser_Matcher { /** - * The element type (tag name) to match or '*' to match any element. + * The element type (tag name) to match. * * @var string */ public $type; + /** + * Whether the selector is the universal selector. + * + * @var bool + */ + private $is_universal; + /** * Constructor. * - * @param string $type The element type (tag name) to match or '*' to match any element. + * @param string $type The element type (tag name) to match. + * @param bool $is_universal Whether the selector is the universal selector. */ - private function __construct( string $type ) { - $this->type = $type; + private function __construct( string $type, bool $is_universal = false ) { + $this->type = $type; + $this->is_universal = $is_universal; } /** @@ -54,7 +63,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * @return bool */ public function matches_tag( string $tag_name ): bool { - if ( '*' === $this->type ) { + if ( $this->is_universal ) { return true; } return 0 === strcasecmp( $tag_name, $this->type ); @@ -70,7 +79,7 @@ public function matches_tag( string $tag_name ): bool { */ public static function parse( WP_CSS_Selector_Token_Stream $tokens ) { if ( $tokens->consume_delim( '*' ) ) { - return new WP_CSS_Type_Selector( '*' ); + return new WP_CSS_Type_Selector( '*', true ); } $result = $tokens->consume_ident(); diff --git a/tests/phpunit/tests/html-api/wpCssTypeSelector.php b/tests/phpunit/tests/html-api/wpCssTypeSelector.php index 917ab005b6980..6d81aa7ff246f 100644 --- a/tests/phpunit/tests/html-api/wpCssTypeSelector.php +++ b/tests/phpunit/tests/html-api/wpCssTypeSelector.php @@ -29,6 +29,47 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin } } + /** + * @ticket 62653 + * + * @dataProvider data_escaped_asterisk_type_selectors + */ + public function test_escaped_asterisk_is_type_selector_not_universal( string $input ) { + $tokens = WP_CSS_Selector_Token_Stream::from_selectors( $input, WP_CSS_Type_Selector::class ); + $result = WP_CSS_Type_Selector::parse( $tokens ); + + $this->assertInstanceOf( WP_CSS_Type_Selector::class, $result ); + $this->assertSame( '*', $result->type ); + $this->assertFalse( $result->matches_tag( 'DIV' ) ); + $this->assertSame( '', $tokens->get_remaining_text() ); + } + + /** + * @ticket 62653 + */ + public function test_literal_asterisk_is_universal_selector() { + $tokens = WP_CSS_Selector_Token_Stream::from_selectors( '*', WP_CSS_Type_Selector::class ); + $result = WP_CSS_Type_Selector::parse( $tokens ); + + $this->assertInstanceOf( WP_CSS_Type_Selector::class, $result ); + $this->assertSame( '*', $result->type ); + $this->assertTrue( $result->matches_tag( 'DIV' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_escaped_asterisk_type_selectors(): array { + return array( + 'identity escape' => array( '\\*' ), + 'lowercase hex escape' => array( '\\2a' ), + 'uppercase hex escape' => array( '\\2A' ), + 'padded hex escape' => array( '\\00002A' ), + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index fcb1acf3fa7d6..d9da35a224062 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -45,6 +45,13 @@ public function test_selects_all_matches( string $html, string $selector, int $m public static function data_selectors(): array { return array( 'any' => array( '

', '*', 5 ), + 'escaped * type selector' => array( '

', '\\*', 0 ), + 'escaped lowercase hex * type' => array( '

', '\\2a', 0 ), + 'escaped uppercase hex * type' => array( '

', '\\2A', 0 ), + 'escaped padded hex * type' => array( '

', '\\00002A', 0 ), + 'escaped p type selector' => array( '

', '\\p', 1 ), + 'escaped hex p type selector' => array( '

', '\\70', 1 ), + 'escaped padded hex p type' => array( '

', '\\000070', 1 ), 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 96bb8e1b4457d..dafa64aba7ec5 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -44,6 +44,13 @@ public static function data_selectors(): array { return array( 'simple type' => array( '

', 'div', 2 ), 'any type' => array( '
', '*', 2 ), + 'escaped * type selector' => array( '

', '\\*', 0 ), + 'escaped lowercase hex * type' => array( '

', '\\2a', 0 ), + 'escaped uppercase hex * type' => array( '

', '\\2A', 0 ), + 'escaped padded hex * type' => array( '

', '\\00002A', 0 ), + 'escaped p type selector' => array( '

', '\\p', 1 ), + 'escaped hex p type selector' => array( '

', '\\70', 1 ), + 'escaped padded hex p type' => array( '

', '\\000070', 1 ), 'simple class' => array( '
', '.x', 2 ), 'simple id' => array( '
', '#x', 2 ), From 981fc3d6fcf2da9cdb0b64c7e83a821c1f3dd12d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 11:34:31 +0200 Subject: [PATCH 199/336] Fix escaped asterisk selector fuzz oracle --- tools/css-selector-fuzz/lib/AstExtractor.php | 43 +++++++++- .../lib/ReferenceMatcher.php | 20 +++-- .../lib/SelectorGenerator.php | 35 ++++++-- tools/css-selector-fuzz/tests/self-check.php | 85 +++++++++++++++++-- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/tools/css-selector-fuzz/lib/AstExtractor.php b/tools/css-selector-fuzz/lib/AstExtractor.php index 593bce73f84ea..4068e3fb4b990 100644 --- a/tools/css-selector-fuzz/lib/AstExtractor.php +++ b/tools/css-selector-fuzz/lib/AstExtractor.php @@ -54,7 +54,7 @@ private static function from_complex( \WP_CSS_Complex_Selector $selector ): arra if ( ! in_array( $pair[1], array( ' ', '>' ), true ) ) { throw new \UnexpectedValueException( 'Context selector uses unsupported combinator: ' . var_export( $pair[1], true ) ); } - $context[] = array( $pair[0]->type, $pair[1] ); + $context[] = self::from_type_selector_context_pair( $pair[0], $pair[1] ); } return array( @@ -79,10 +79,49 @@ private static function from_compound( \WP_CSS_Compound_Selector $selector ): ar throw new \UnexpectedValueException( 'Compound selector has neither type nor subclass selectors.' ); } - return array( + $out = array( 'type' => null === $selector->type_selector ? null : $selector->type_selector->type, 'subs' => $subs, ); + if ( null !== $selector->type_selector ) { + self::add_type_selector_metadata( $out, $selector->type_selector ); + } + + return $out; + } + + private static function from_type_selector_context_pair( \WP_CSS_Type_Selector $selector, string $combinator ): array { + $pair = array( $selector->type, $combinator ); + if ( self::should_disambiguate_asterisk_type_selector( $selector ) ) { + $pair[] = false; + } + return $pair; + } + + private static function add_type_selector_metadata( array &$compound, \WP_CSS_Type_Selector $selector ): void { + if ( self::should_disambiguate_asterisk_type_selector( $selector ) ) { + $compound['typeIsUniversal'] = false; + } + } + + private static function should_disambiguate_asterisk_type_selector( \WP_CSS_Type_Selector $selector ): bool { + $is_universal = self::type_selector_is_universal( $selector ); + if ( $is_universal && '*' !== $selector->type ) { + throw new \UnexpectedValueException( 'Universal type selector has unexpected type: ' . var_export( $selector->type, true ) ); + } + return '*' === $selector->type && ! $is_universal; + } + + private static function type_selector_is_universal( \WP_CSS_Type_Selector $selector ): bool { + $reflection = new \ReflectionProperty( \WP_CSS_Type_Selector::class, 'is_universal' ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } + $value = $reflection->getValue( $selector ); + if ( ! is_bool( $value ) ) { + throw new \UnexpectedValueException( 'Type selector universal flag is not a boolean.' ); + } + return $value; } private static function from_subclass( $sub ): array { diff --git a/tools/css-selector-fuzz/lib/ReferenceMatcher.php b/tools/css-selector-fuzz/lib/ReferenceMatcher.php index ea422078e9893..1b61be59cff47 100644 --- a/tools/css-selector-fuzz/lib/ReferenceMatcher.php +++ b/tools/css-selector-fuzz/lib/ReferenceMatcher.php @@ -168,11 +168,13 @@ private static function explore_context( array $context, array $ancestor_tags ): return false; } - list( $type, $combinator ) = $context[0]; - $rest = array_slice( $context, 1 ); + $pair = $context[0]; + list( $type, $combinator ) = $pair; + $is_universal = array_key_exists( 2, $pair ) ? (bool) $pair[2] : null; + $rest = array_slice( $context, 1 ); if ( '>' === $combinator ) { - return self::type_matches( $type, $ancestor_tags[0] ) + return self::type_matches( $type, $ancestor_tags[0], $is_universal ) && self::explore_context( $rest, array_slice( $ancestor_tags, 1 ) ); } @@ -180,7 +182,7 @@ private static function explore_context( array $context, array $ancestor_tags ): $count = count( $ancestor_tags ); for ( $i = 0; $i < $count; $i++ ) { if ( - self::type_matches( $type, $ancestor_tags[ $i ] ) && + self::type_matches( $type, $ancestor_tags[ $i ], $is_universal ) && self::explore_context( $rest, array_slice( $ancestor_tags, $i + 1 ) ) ) { return true; @@ -190,7 +192,8 @@ private static function explore_context( array $context, array $ancestor_tags ): } public static function compound_matches( array $compound, array $row, bool $quirks, bool $html_attr_ci = true ): bool { - if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $row['tag'] ) ) { + $is_universal = array_key_exists( 'typeIsUniversal', $compound ) ? (bool) $compound['typeIsUniversal'] : null; + if ( null !== $compound['type'] && ! self::type_matches( $compound['type'], $row['tag'], $is_universal ) ) { return false; } foreach ( (array) $compound['subs'] as $sub ) { @@ -201,8 +204,11 @@ public static function compound_matches( array $compound, array $row, bool $quir return true; } - private static function type_matches( string $type, string $tag ): bool { - return '*' === $type || ascii_strtolower( $type ) === ascii_strtolower( $tag ); + private static function type_matches( string $type, string $tag, ?bool $is_universal = null ): bool { + if ( null === $is_universal ) { + $is_universal = '*' === $type; + } + return $is_universal || ascii_strtolower( $type ) === ascii_strtolower( $tag ); } private static function sub_matches( array $sub, array $row, bool $quirks, bool $html_attr_ci ): bool { diff --git a/tools/css-selector-fuzz/lib/SelectorGenerator.php b/tools/css-selector-fuzz/lib/SelectorGenerator.php index 90eadaa2f2427..86f91bc99f46f 100644 --- a/tools/css-selector-fuzz/lib/SelectorGenerator.php +++ b/tools/css-selector-fuzz/lib/SelectorGenerator.php @@ -100,13 +100,17 @@ public static function render_canonical( array $list_ast ): string { $out = ''; foreach ( array_reverse( $complex['context'] ) as $pair ) { list( $type, $combinator ) = $pair; - $out .= '*' === $type ? '*' : self::canonical_ident( $type ); + $out .= self::type_is_universal( $type, self::context_type_is_universal( $pair ) ) + ? '*' + : self::canonical_ident( $type ); $out .= '>' === $combinator ? ' > ' : ' '; } $compound = $complex['self']; if ( null !== $compound['type'] ) { - $out .= '*' === $compound['type'] ? '*' : self::canonical_ident( $compound['type'] ); + $out .= self::type_is_universal( $compound['type'], self::compound_type_is_universal( $compound ) ) + ? '*' + : self::canonical_ident( $compound['type'] ); } foreach ( (array) $compound['subs'] as $sub ) { switch ( $sub['kind'] ) { @@ -312,8 +316,8 @@ public static function generate( Prng $prng, array $pools, ?array $rows = null, * parsed WP_CSS_* objects): * * list: array of complex - * complex: array( 'context' => array( array( type, combinator ) ... right-to-left ), 'self' => compound ) - * compound: array( 'type' => string|null, 'subs' => array|null ) + * complex: array( 'context' => array( array( type, combinator[, is_universal] ) ... right-to-left ), 'self' => compound ) + * compound: array( 'type' => string|null, 'subs' => array|null, 'typeIsUniversal' => bool optional ) * sub: array( 'kind' => 'class'|'id', 'name' => string ) * | array( 'kind' => 'attr', 'name' => string, 'matcher' => string|null, * 'value' => string|null, 'modifier' => string|null ) @@ -1138,7 +1142,7 @@ private function path_near_miss( array $list, array $element ): array { $fid = $element['fid']; $flips = array( 'wrong-class', 'wrong-attr' ); - if ( null !== $compound['type'] && '*' !== $compound['type'] ) { + if ( null !== $compound['type'] && ! self::type_is_universal( $compound['type'], self::compound_type_is_universal( $compound ) ) ) { $flips[] = 'wrong-type'; } foreach ( $complex['context'] as $pair ) { @@ -1156,6 +1160,7 @@ private function path_near_miss( array $list, array $element ): array { $other = $this->prng->choice( DocumentGenerator::SAFE_TAGS ); } while ( $other === $tag ); $complex['self']['type'] = $this->prng->chance( 25 ) ? $this->random_case( $other ) : $other; + unset( $complex['self']['typeIsUniversal'] ); return array( array( $complex ), null, $fid ); case 'wrong-attr': @@ -1227,7 +1232,9 @@ private function render_complex( array $complex ): string { $reversed = array_reverse( $complex['context'] ); foreach ( $reversed as $pair ) { list( $type, $combinator ) = $pair; - $rendered_type = '*' === $type ? '*' : $this->render_ident( $type ); + $rendered_type = self::type_is_universal( $type, self::context_type_is_universal( $pair ) ) + ? '*' + : $this->render_ident( $type ); $out .= $rendered_type; if ( '>' === $combinator ) { $before = $this->maybe_ws( 50 ); @@ -1247,7 +1254,9 @@ private function render_complex( array $complex ): string { private function render_compound( array $compound ): string { $out = ''; if ( null !== $compound['type'] ) { - $out .= '*' === $compound['type'] ? '*' : $this->render_ident( $compound['type'] ); + $out .= self::type_is_universal( $compound['type'], self::compound_type_is_universal( $compound ) ) + ? '*' + : $this->render_ident( $compound['type'] ); } foreach ( (array) $compound['subs'] as $sub ) { switch ( $sub['kind'] ) { @@ -1265,6 +1274,18 @@ private function render_compound( array $compound ): string { return $out; } + private static function context_type_is_universal( array $pair ): ?bool { + return array_key_exists( 2, $pair ) ? (bool) $pair[2] : null; + } + + private static function compound_type_is_universal( array $compound ): ?bool { + return array_key_exists( 'typeIsUniversal', $compound ) ? (bool) $compound['typeIsUniversal'] : null; + } + + private static function type_is_universal( string $type, ?bool $explicit ): bool { + return null === $explicit ? '*' === $type : $explicit; + } + private function render_attr_selector( array $sub ): string { $out = '[' . $this->maybe_ws( 20 ) . $this->render_ident( $sub['name'] ) . $this->maybe_ws( 20 ); diff --git a/tools/css-selector-fuzz/tests/self-check.php b/tools/css-selector-fuzz/tests/self-check.php index 79c7dd7cfeff4..7815919cc759b 100644 --- a/tools/css-selector-fuzz/tests/self-check.php +++ b/tools/css-selector-fuzz/tests/self-check.php @@ -436,13 +436,7 @@ function select_fids( string $html, string $selector ): array { check( array( 'e4' ) === select_fids( $known_html, '[data-v|="hello"]' ), 'Known: [data-v|=hello].' ); check( array( 'e7' ) === select_fids( $known_html, '[lang^="en"]' ), 'Known: [lang^=en].' ); -// --- Class-value decode boundary (ReferenceMatcher vs WP class_list) -------- -// WP's class_list() folds NUL -> U+FFFD and treats FF as a separator; the -// reference matcher reimplements tokenization independently. Pin both engines -// against each other on these boundary inputs; randomized generator sampling -// above verifies that the same NUL boundary is present in the hot path. Each -// case also checks the reference matcher agrees with select() over a -// TreeCapture of the same markup. +// --- Known-answer matcher helpers ------------------------------------------ function ref_fids( string $html, string $selector ): array { $capture = \CssSelectorFuzz\TreeCapture::capture( $html ); @@ -454,6 +448,83 @@ function ref_fids( string $html, string $selector ): array { return \CssSelectorFuzz\ReferenceMatcher::expected_html_matches_rows( $ast, $capture['htmlRows'], $capture['quirks'] ); } +function complex_ast( string $selector ): ?array { + $list = WP_CSS_Complex_Selector_List::from_selectors( $selector ); + return null === $list ? null : \CssSelectorFuzz\AstExtractor::from_complex_list( $list ); +} + +// --- Escaped asterisk type selectors --------------------------------------- + +$literal_asterisk_ast = complex_ast( '*' ); +check( null !== $literal_asterisk_ast, 'Asterisk type distinction: literal universal parses.' ); +if ( null !== $literal_asterisk_ast ) { + check( '*' === $literal_asterisk_ast[0]['self']['type'], 'Asterisk type distinction: literal universal decodes to "*".' ); + check( ! array_key_exists( 'typeIsUniversal', $literal_asterisk_ast[0]['self'] ), 'Asterisk type distinction: literal universal keeps legacy AST shape.' ); +} + +foreach ( array( '\\*', '\\2a', '\\2A', '\\00002A' ) as $selector ) { + $ast = complex_ast( $selector ); + check( null !== $ast, "Asterisk type distinction: escaped selector parses ({$selector})." ); + if ( null === $ast ) { + continue; + } + check( '*' === $ast[0]['self']['type'], "Asterisk type distinction: escaped selector decodes to type * ({$selector})." ); + check( false === ( $ast[0]['self']['typeIsUniversal'] ?? null ), "Asterisk type distinction: escaped selector is not universal ({$selector})." ); + check( $literal_asterisk_ast !== $ast, "Asterisk type distinction: escaped selector AST differs from literal universal ({$selector})." ); +} + +$escaped_asterisk_item_ast = array( + array( + 'context' => array(), + 'self' => array( + 'type' => '*', + 'subs' => array( array( 'kind' => 'class', 'name' => 'item' ) ), + 'typeIsUniversal' => false, + ), + ), +); +$canonical_escaped_asterisk_item = SelectorGenerator::render_canonical( $escaped_asterisk_item_ast ); +check( + $escaped_asterisk_item_ast === complex_ast( $canonical_escaped_asterisk_item ), + 'Asterisk type distinction: canonical renderer escapes literal type *.' +); +for ( $seed = 1; $seed <= 30; $seed++ ) { + $rendered = SelectorGenerator::render( new Prng( (string) $seed, 'self-check-escaped-asterisk-type' ), $escaped_asterisk_item_ast ); + check( + $escaped_asterisk_item_ast === complex_ast( $rendered ), + "AST renderer escaped type * seed {$seed}: AST round-trips for " . \CssSelectorFuzz\printable_bytes( $rendered ) + ); +} + +$asterisk_html = '' + . '

' + . ''; + +foreach ( + array( + '*.item' => array( 'a3', 'a4' ), + '\\*.item' => array(), + '\\2a.item' => array(), + '\\00002A.item' => array(), + '* > .item' => array( 'a3', 'a4' ), + '\\* > .item' => array(), + '\\2a > .item' => array(), + ) as $selector => $expected +) { + $wp = select_fids( $asterisk_html, $selector ); + $ref = ref_fids( $asterisk_html, $selector ); + check( $expected === $wp, "Asterisk type distinction ({$selector}): select() == expected." ); + check( $ref === $wp, "Asterisk type distinction ({$selector}): ReferenceMatcher == select()." ); +} + +// --- Class-value decode boundary (ReferenceMatcher vs WP class_list) -------- +// WP's class_list() folds NUL -> U+FFFD and treats FF as a separator; the +// reference matcher reimplements tokenization independently. Pin both engines +// against each other on these boundary inputs; randomized generator sampling +// above verifies that the same NUL boundary is present in the hot path. Each +// case also checks the reference matcher agrees with select() over a +// TreeCapture of the same markup. + $nul_html = ""; $ff_html = ""; From 504582864a1b4eda3c0d09bf6a9178b807d4a90e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 26 Jun 2026 09:12:06 +0200 Subject: [PATCH 200/336] Ignore generated Lexbor harness --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 15876fa47fee8..03dfe96ce5672 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ wp-tests-config.php /packagehash.txt /.gutenberg-hash /artifacts +/tools/css-selector-fuzz/lexbor/harness /setup.log /coverage From 2459eed5574683a94d17d499b8823d2d72a6427e Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 23 Jul 2026 15:33:51 +0000 Subject: [PATCH 201/336] Comments: allow the Notes @mention chip markup in comment content. The Notes @mention completer stores a mention as a non-interactive chip, `@Name`, the `user-N` class token carrying the mentioned user's ID. Fix an issue where the default comment kses allowlist does not permit `span`, so for users without the `unfiltered_html` capability the mention markup is stripped when the note is saved. See related Gutenberg pull requests: https://github.com/WordPress/gutenberg/pull/79604 and https://github.com/WordPress/gutenberg/pull/80528. Props mamaduka, westonruter, t-hamano, luisdavid01, vedantere. Fixes #65622. git-svn-id: https://develop.svn.wordpress.org/trunk@62832 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/default-filters.php | 5 + src/wp-includes/kses.php | 83 +++++++++++ tests/phpunit/tests/kses.php | 221 ++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index d9a05c829646a..b2790c7d43ec9 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -310,6 +310,11 @@ add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 ); + +// Note mention chips in comment content: allow `span` through comment kses, +// then reduce its classes to the mention tokens right after `wp_filter_kses`. +add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_span', 10, 2 ); +add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11 ); add_filter( 'comment_email', 'antispambot' ); add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' ); add_filter( 'option_category_base', '_wp_filter_taxonomy_base' ); diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 37d457a3e18a2..46cd2c4576c03 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1131,6 +1131,89 @@ function wp_kses_allowed_html( $context = '' ) { } } +/** + * Allows the note mention chip markup in comment content. + * + * The notes `@` mention completer stores a mention as a chip carrying the + * mentioned user's ID in a class token: + * `@Name`. The default comment + * allowlist does not allow `span` at all, so for users without + * `unfiltered_html` the mention would be stripped on save. + * + * The allowance is deliberately narrow and always on: `span` is a + * semantics-free element and _wp_kses_sanitize_note_mention_classes() + * reduces its `class` to the two mention tokens right after kses runs, so + * regular (including anonymous) commenters gain nothing beyond the inert + * mention markup itself. + * + * @since 7.1.0 + * @access private + * + * @param array> $allowed The allowed tags structure for the context. + * @param string $context The kses context. + * @return array> Modified allowed tags structure. + */ +function _wp_kses_allow_note_mention_span( $allowed, $context ): array { + if ( ! is_array( $allowed ) ) { + $allowed = array(); + } + if ( 'pre_comment_content' !== $context ) { + return $allowed; + } + + if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) { + $allowed['span'] = array(); + } + + $allowed['span']['class'] = true; + + return $allowed; +} + +/** + * Reduces `span` classes in comment content to the note mention tokens. + * + * _wp_kses_allow_note_mention_span() lets `class` through kses on `span` so + * the mention chip survives, but `class` is an open-ended styling and + * scripting hook, so this companion pass - running right after + * `wp_filter_kses` at priority 10 - strips every class token except the two + * the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only + * comment tag allowed to carry `class` at all, so walking `span` tags covers + * the entire allowance. + * + * The pass only applies while the restrictive comment allowlist is active: + * users with `unfiltered_html` are filtered through `wp_filter_post_kses` + * (or not at all), where arbitrary classes are already permitted, and + * narrowing their markup here would restrict what core allows them to post. + * + * @since 7.1.0 + * @access private + * + * @param string $content Slashed comment content, already filtered by kses. + * @return string Slashed comment content with span classes reduced. + */ +function _wp_kses_sanitize_note_mention_classes( $content ): string { + if ( ! is_string( $content ) ) { + $content = ''; + } + if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) { + return $content; + } + + $processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) ); + + while ( $processor->next_tag( 'SPAN' ) ) { + foreach ( $processor->class_list() as $token ) { + if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) { + // Removing the last class also removes the attribute itself. + $processor->remove_class( $token ); + } + } + } + + return wp_slash( $processor->get_updated_html() ); +} + /** * You add any KSES hooks here. * diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 59353a2b7a20c..1afd7e0884a64 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() { $this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) ); } + /** + * Tests that the comment content context allows only the mention span beyond the defaults. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() { + global $allowedtags; + + $allowed = wp_kses_allowed_html( 'pre_comment_content' ); + + $this->assertSame( + array( 'class' => true ), + $allowed['span'], + 'The mention span should be allowed in comment content.' + ); + + unset( $allowed['span'] ); + $this->assertSame( + $allowedtags, + $allowed, + 'Nothing beyond the mention span should be allowed on top of the default comment tags.' + ); + } + + /** + * Tests that a note mention survives content sanitization of a `note` comment. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_markup_survives_note_content_sanitization() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); + } + + /** + * Tests that the mention markup also survives in regular comment content. + * + * The allowance is always on rather than scoped per comment type: the + * mention markup is inert, so uniform sanitization avoids stateful + * arming and disarming of kses filters around each note write. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_markup_survives_regular_comment_content_sanitization() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); + + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); + } + + /** + * Tests that span classes are reduced to the two mention tokens. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.' + ); + } + + /** + * Tests that class tokens are reduced on spans regardless of tag-name casing. + * + * kses preserves tag-name casing, so the class reduction must match `SPAN` + * case-insensitively rather than bail on a `get_mention_commentdata( 'note', $content ) ) ); + + $this->assertEqualHTML( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + '', + 'Class tokens should be reduced on spans regardless of tag-name casing.' + ); + } + + /** + * Tests that the class attribute is removed when no mention tokens remain. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_class_attribute_removed_when_no_tokens_remain() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello there!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); + + // Markup-equivalence assertion: the HTML API's whitespace handling + // when removing the final attribute is not part of its contract. + $this->assertEqualHTML( + 'Hello there!', + wp_unslash( $filtered['comment_content'] ), + '', + 'A span with no valid mention tokens should lose its class attribute entirely.' + ); + } + + /** + * Tests that only the `class` attribute is allowed on mention spans. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_note_mention_allows_only_class_on_mention_spans() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'Attributes beyond `class` should be stripped from spans.' + ); + } + + /** + * Tests that `class` is still stripped from links in comment content. + * + * @ticket 65622 + * + * @covers ::_wp_kses_allow_note_mention_span + */ + public function test_class_is_still_stripped_from_links_in_comment_content() { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + + /* + * The href is external to the test site so that wp_rel_ugc() - which + * applies to notes like any other comment - deterministically appends + * `rel="nofollow ugc"`. + */ + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'The class allowance is scoped to spans; links keep the default sanitization.' + ); + } + + /** + * Tests that the class reduction is skipped while the restrictive comment kses is inactive. + * + * Users with `unfiltered_html` are filtered through `wp_filter_post_kses` + * (or not at all), where arbitrary classes are permitted; the mention + * class reduction must not narrow what they can post. + * + * @ticket 65622 + * + * @covers ::_wp_kses_sanitize_note_mention_classes + */ + public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive() { + // kses_init() hooks wp_filter_kses by default in the test + // environment, so detach it to simulate the unfiltered_html setup. + // The test framework restores filters after each test. + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $content = 'Hello there!'; + + $this->assertSame( + wp_slash( $content ), + _wp_kses_sanitize_note_mention_classes( wp_slash( $content ) ), + 'Span classes should be left untouched when wp_filter_kses is not active.' + ); + } + + /** + * Builds a complete commentdata array for wp_filter_comment(). + * + * @param 'note'|'comment' $comment_type The comment type. + * @param string $content The comment content. + * @return array{ + * comment_content: string, + * ... + * } + */ + private function get_mention_commentdata( string $comment_type, string $content ): array { + return array( + 'comment_content' => $content, + 'comment_type' => $comment_type, + 'comment_author' => 'admin', + 'comment_author_IP' => '127.0.0.1', + 'comment_author_url' => 'http://example.org', + 'comment_author_email' => 'admin@example.org', + 'comment_agent' => '', + ); + } + public function test_hyphenated_tag() { $content = 'Alot of hyphens.'; $custom_tags = array( From b0e62d8e0ae405ab3f36a87d4181885e2de2e0e8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 23 Jul 2026 16:18:56 +0000 Subject: [PATCH 202/336] Docs: Require view config filter callbacks to return the container. Corrects the get_entity_view_config_{$kind}_{$name} filter docblock: callbacks must return the container they receive. Also fixes a doubled "the" in the same paragraph. Follow-up to [62825]. Props jorgefilipecosta, oandregal. Fixes #65577. git-svn-id: https://develop.svn.wordpress.org/trunk@62833 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-view-config-data.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php index 99ea816aee96e..b2e107608ac84 100644 --- a/src/wp-includes/class-wp-view-config-data.php +++ b/src/wp-includes/class-wp-view-config-data.php @@ -159,10 +159,12 @@ public function apply_filters( $kind, $name ) { * individual list members. * * A change that declares an unsupported schema version is rejected and does - * not alter anything. Callbacks mutate the container in place, so there is no - * need to return it; any returned value is ignored. Callbacks must not replace - * the container with a different value, as later callbacks receive whatever the - * the previous one returned. + * not alter anything. As with any filter, each callback's return value is + * passed to the next callback as `$data`, so callbacks must return the + * container they received: a callback that returns nothing, or any other + * value, hands that result to every callback hooked at a later priority + * instead of the container. Since the write methods return the container, + * a callback can end with `return $data->merge( $patch, $version );`. * * @since 7.1.0 * From 5fd5a8eec5cfb3e1cd74695dabb5c6b93666dcf2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 23 Jul 2026 16:37:54 +0000 Subject: [PATCH 203/336] View config: reject shape-mismatched merges, define empty-array semantics, strip nulls from appended members. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three silent data-loss defects in WP_View_Config_Data's merge engine: - An associative patch value over a list (or a non-empty list over an associative value) discarded the whole current value. It is now rejected with _doing_it_wrong() and the current value is kept. - An empty array under merge() wiped associative values but no-oped on lists. It is now a no-op for both shapes — clear a list with replace() and an empty list, reset a key with null. - A list member appended by merge() kept nested nulls that every other write path drops. Appended members now go through strip_nulls(). Follow-up to [62825]. Props jorgefilipecosta, oandregal. See #65577. git-svn-id: https://develop.svn.wordpress.org/trunk@62834 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-view-config-data.php | 63 ++++- tests/phpunit/tests/view-config-data.php | 227 ++++++++++++++++++ 2 files changed, 287 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php index b2e107608ac84..8c3d255fab82d 100644 --- a/src/wp-includes/class-wp-view-config-data.php +++ b/src/wp-includes/class-wp-view-config-data.php @@ -40,7 +40,10 @@ * key by key (an associative array merges member by member, a nested `null` * deletes just that leaf, a scalar replaces just that value), while `set()` * swaps the whole value. A nested `null` deletes just the leaf it names in - * every case. Each patch also declares the configuration schema + * every case. A patch value whose shape does not match the current value — + * an associative array where a list lives, or the reverse — is rejected with + * a notice rather than merged, and an empty array under `merge()` is a + * no-op. Each patch also declares the configuration schema * version it was written against (currently 1), so a future WordPress release * that changes the configuration shape can migrate existing patches forward * instead of breaking them. @@ -308,6 +311,12 @@ public function remove( array $spec, int $version ) { * stops inheriting core's future additions to it — but it's useful when a * contributor needs to pin a list to an exact set of members. * + * The shape rule applies here too: a patch value whose shape does not match + * the current value — an associative array where a list lives, or a + * non-empty list where an associative value lives — is rejected with a + * notice and leaves the current value unchanged. An empty array is exempt, + * so replacing a list with an empty list still clears it. + * * A patch that declares an unsupported schema version is rejected and does * not change anything. * @@ -354,6 +363,13 @@ public function replace( array $patch, int $version ) { * - default_layouts will be updated so that newField is appended to the badgeFields. * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'. * + * A patch value only merges into a current value of the same shape: an + * associative array where a list lives, or a non-empty list where an + * associative value lives, is rejected with a notice and leaves the current + * value unchanged. An empty array merges nothing and is a no-op — clear a + * list with replace() and an empty list, or reset a key to its default with + * a top-level `null`. + * * A patch that declares an unsupported schema version is rejected and does * not change anything. * @@ -477,6 +493,15 @@ private function strip_nulls( $value ) { * $replace_lists flag is carried down through associative nesting so that, * under replace(), every list reached along the way is swapped wholesale. * + * An array in $incoming only merges into a current value of the same shape. + * A non-empty mismatch — an associative array where a list lives, or a + * non-empty list where an associative value lives — is reported with + * _doing_it_wrong() and leaves the current value unchanged, so a malformed + * patch cannot silently destroy configuration. An empty array is + * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is + * spelled replace() with an empty list, and resetting a key is spelled + * `null`. + * * @since 7.1.0 * * @param mixed $current The current value. @@ -493,6 +518,18 @@ private function merge_properties( $current, $incoming, $replace_lists ) { // Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0). if ( array_is_list( $incoming ) ) { + // A non-empty list only lands where a list (or nothing) lives, under + // merge() and replace() alike. An empty array is shape-ambiguous and + // exempt, so replace() with an empty list can still clear a list. + if ( array() !== $incoming && is_array( $current ) && ! array_is_list( $current ) && array() !== $current ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ), + '7.1.0' + ); + return $current; + } + // replace() takes an incoming list as-is; merge() merges it by member identity. if ( $replace_lists ) { // As-is except for nulls: a list swapped in wholesale has no @@ -500,6 +537,13 @@ private function merge_properties( $current, $incoming, $replace_lists ) { // set()), so a null member is dropped rather than stored. return $this->strip_nulls( $incoming ); } + + // An empty list has no members to merge, and an empty array is + // shape-ambiguous, so merging one is a no-op rather than a reset. + if ( array() === $incoming ) { + return $current; + } + return $this->merge_list_by_identity( is_array( $current ) && array_is_list( $current ) ? $current : array(), $incoming @@ -507,6 +551,15 @@ private function merge_properties( $current, $incoming, $replace_lists ) { } // Consider any other array as associative (keys are strings). + if ( is_array( $current ) && array_is_list( $current ) && array() !== $current ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.' ), + '7.1.0' + ); + return $current; + } + $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array(); foreach ( $incoming as $key => $value ) { // A null patch value deletes the property. @@ -603,7 +656,9 @@ private function remove_list_member( array $members, $identity ) { * A member of the incoming list whose identity matches one already present * merges into it in place, keeping its position; an unmatched member is * appended to the end, except a literal `null`, which carries no identity - * and holds nothing to merge and so is dropped. A matched member's contents + * and holds nothing to merge and so is dropped. An appended member has no + * existing leaf for a nested `null` to delete (the same rationale as set()), + * so its nulls are stripped rather than stored. A matched member's contents * merge recursively with the same rules (merge_properties), so the * identity-aware merge applies at * any nesting level: each key named by the patch is substituted while the @@ -639,7 +694,9 @@ private function merge_list_by_identity( array $current, array $incoming ) { } } if ( null === $index ) { - $result[] = $item; + // An appended member has no existing leaf for a nested null to + // delete, so nulls are dropped rather than stored. + $result[] = $this->strip_nulls( $item ); continue; } diff --git a/tests/phpunit/tests/view-config-data.php b/tests/phpunit/tests/view-config-data.php index 1d56adb786644..68940ad6c2d5c 100644 --- a/tests/phpunit/tests/view-config-data.php +++ b/tests/phpunit/tests/view-config-data.php @@ -1721,6 +1721,233 @@ public function test_merge_rejects_unknown_key() { $this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), self::read_config( $data ) ); } + /** + * merge() rejects an associative patch value where a list lives: the shapes + * do not line up, so merging would have to guess what the string keys mean. + * The current list survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_rejects_associative_patch_over_a_list() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + $before = self::read_config( $data ); + + // The pre-7.1 slug-keyed shape, not the documented list of members. + $data->merge( + array( + 'view_list' => array( + 'published' => array( 'title' => 'Live' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * merge() rejects a non-empty list patch value where an associative value + * lives, the mirror of the associative-over-list mismatch: the current map + * survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_rejects_list_patch_over_an_associative_value() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->merge( + array( + 'default_view' => array( + 'sort' => array( 'title', 'asc' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * An empty array under merge() is a no-op for both shapes: it has no + * members to merge, and being shape-ambiguous it must not reset the + * current value either. Clearing a list is spelled replace() with an + * empty list; resetting a key is spelled null. + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_empty_array_is_a_noop() { + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'filters' => array( + array( + 'field' => 'author', + 'operator' => 'isAny', + ), + ), + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->merge( + array( + 'default_view' => array( + 'filters' => array(), + 'sort' => array(), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * A nested null deletes just the leaf it names in every case, including + * inside a list member that did not exist yet: an appended member has no + * existing leaf to delete, so its nulls are dropped rather than stored + * (the same rationale as set() and the lists replace() swaps in). + * + * @ticket 65577 + * + * @covers ::merge + */ + public function test_merge_appended_member_drops_nested_nulls() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + $data->merge( + array( + 'view_list' => array( + array( + 'slug' => 'mine', + 'view' => array( 'filters' => null ), + ), + ), + ), + 1 + ); + + $this->assertSame( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + array( + 'slug' => 'mine', + 'view' => array(), + ), + ), + ), + self::read_config( $data ) + ); + } + + /** + * replace() rejects a non-empty list patch value where an associative value + * lives, the same rule merge() enforces: a list in the patch replaces the + * current list wholesale, but it cannot land where a map lives. The current + * map survives untouched instead of being discarded. + * + * @ticket 65577 + * + * @covers ::replace + */ + public function test_replace_rejects_list_patch_over_an_associative_value() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge_properties' ); + + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $before = self::read_config( $data ); + + $data->replace( + array( + 'default_view' => array( + 'sort' => array( 'title', 'asc' ), + ), + ), + 1 + ); + + $this->assertSame( $before, self::read_config( $data ) ); + } + + /** + * An empty array is exempt from the shape guard, so replace() with an + * empty list stays the documented way to clear a list. + * + * @ticket 65577 + * + * @covers ::replace + */ + public function test_replace_empty_list_still_clears_a_list() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'slug' => 'all', + 'title' => 'All items', + ), + ), + ) + ); + + $data->replace( array( 'view_list' => array() ), 1 ); + + $this->assertSame( array( 'view_list' => array() ), self::read_config( $data ) ); + } + /** * merge() treats a scalar list member as its own identity: an incoming From 78e0b517ad73b95dbfb5f81f52f5ac0a375950d4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 23 Jul 2026 20:14:22 +0000 Subject: [PATCH 204/336] Code Quality: Preserve `string[]` input type in `wp_parse_list()` return. This prevents unintentional widening of a `string[]` input to a `scalar[]` output, since strings are scalars. Follow-up to r62797. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62835 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/functions.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index dca95d69b69fe..9a688b9866ce0 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -5034,9 +5034,13 @@ function wp_parse_args( $args, $defaults = array() ) { * @since 5.1.0 * * @param mixed[]|string $input_list List of values. - * @return array Array of values. A string is split into a list, while an array + * @return array Array of scalar values. A string is split into a list, while an array * keeps its keys, so the result is not necessarily a list. - * @phpstan-return ( $input_list is string ? list : array ) + * @phpstan-return ( + * $input_list is string ? list : ( + * $input_list is array ? array : array + * ) + * ) */ function wp_parse_list( $input_list ): array { if ( ! is_array( $input_list ) ) { From 4f0a5c704a04228ff924747c3f9ad3c2489aba16 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 23 Jul 2026 20:37:50 +0000 Subject: [PATCH 205/336] Code Quality: Document that `sanitize_key()` returns `lowercase-string`. This is a narrower PHPStan type compared to just `string`. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62836 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/formatting.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 8f8af4a082196..74a28109b6536 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2186,6 +2186,7 @@ function sanitize_user( $username, $strict = false ) { * * @param string $key String key. * @return string Sanitized key. + * @phpstan-return lowercase-string */ function sanitize_key( $key ) { $sanitized_key = ''; From 8f7c6bc192161722fcc4769aaacfcc350dd3a7a9 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 23 Jul 2026 20:41:20 +0000 Subject: [PATCH 206/336] Docs: Correct the type for `WP_Screen::$_screen_settings`. This reflects the property's initial `null` state prior to initialization. Follow-up to [55693], [61300]. Props Chouby, arkaprabhachowdhury, SergeyBiryukov. Fixes #56607. git-svn-id: https://develop.svn.wordpress.org/trunk@62837 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-screen.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-screen.php b/src/wp-admin/includes/class-wp-screen.php index ab7dfef77f67c..b0b689d412edb 100644 --- a/src/wp-admin/includes/class-wp-screen.php +++ b/src/wp-admin/includes/class-wp-screen.php @@ -89,7 +89,7 @@ final class WP_Screen { * have a `$parent_base` of 'edit'. * * @since 3.3.0 - * @var string|null + * @var ?string */ public $parent_base; @@ -99,7 +99,7 @@ final class WP_Screen { * Some `$parent_file` values are 'edit.php?post_type=page', 'edit.php', and 'options-general.php'. * * @since 3.3.0 - * @var string|null + * @var ?string */ public $parent_file; @@ -186,7 +186,7 @@ final class WP_Screen { * Stores the 'screen_settings' section of screen options. * * @since 3.3.0 - * @var string + * @var ?string */ private $_screen_settings; From 0029901b39ca9389114f1c59240504e99598daa7 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Thu, 23 Jul 2026 20:59:29 +0000 Subject: [PATCH 207/336] Administration: Use post title column as table header in post lists. The `select` column has been the `th` with row scope for post list tables since at least 2010. This results in a row name for screen readers that is based on the checkbox input and its label, which can be an empty value when that input is not available. Move the `th` to the post title column, change the select column to `td`, and add `aria-label` to the `th` to provide a simplified row name to supporting screen readers. Styles are additive, to retain support for custom list table implementations. Developed in https://github.com/WordPress/wordpress-develop/pull/9761 Props afercia, abcd95, ozgursar, nikunj8866, joedolson. Fixes #32892. git-svn-id: https://develop.svn.wordpress.org/trunk@62838 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 6 ++- src/wp-admin/css/forms.css | 3 +- src/wp-admin/css/list-tables.css | 37 +++++++++---- src/wp-admin/includes/class-wp-list-table.php | 53 +++++++++++++++---- .../class-wp-ms-themes-list-table.php | 8 +-- .../includes/class-wp-plugins-list-table.php | 6 +-- .../includes/class-wp-posts-list-table.php | 22 +++++++- .../includes/class-wp-users-list-table.php | 14 +++-- 8 files changed, 114 insertions(+), 35 deletions(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index c2ab1c31b5c34..88c1c0d6ac0d1 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -514,6 +514,7 @@ code { } .widefat th, +.widefat tbody td.check-column, .widefat thead td, .widefat tfoot td { text-align: left; @@ -521,6 +522,7 @@ code { font-size: 14px; } +.widefat td.check-column input, .widefat th input, .updates-table td input, .widefat thead td input, @@ -536,12 +538,14 @@ code { vertical-align: top; } -.widefat tbody th.check-column { +.widefat tbody th.check-column, +.widefat tbody td.check-column { padding: 9px 0 22px; } .widefat thead td.check-column, .widefat tbody th.check-column, +.widefat tbody td.check-column, .updates-table tbody td.check-column, .widefat tfoot td.check-column { padding: 11px 0 0 3px; diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index c17d038c5d2c6..dd19e1ba8070a 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -1967,7 +1967,8 @@ table.form-table td .updated p { margin-left: 0; } - .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) td.column-primary:not(.check-column) { + .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) td.column-primary:not(.check-column), + .wp-list-table.privacy_requests tr:not(.inline-edit-row):not(.no-items) th.column-primary:not(.check-column) { display: table-cell; } diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 731168f97fc8d..46c2002e3e3a1 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -222,11 +222,13 @@ background-color: #fcf9e8; } -#the-comment-list .unapproved th.check-column { +#the-comment-list .unapproved th.check-column, +#the-comment-list .unapproved td.check-column { border-left: 4px solid #d63638; } -#the-comment-list .unapproved th.check-column input { +#the-comment-list .unapproved th.check-column input, +#the-comment-list .unapproved td.check-column input { margin-left: 4px; } @@ -1221,11 +1223,13 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before { ------------------------------------------------------------------------------*/ .plugins tbody th.check-column, +.plugins tbody td.check-column, .plugins tbody { padding: 8px 0 0 2px; } -.plugins tbody th.check-column input[type=checkbox] { +.plugins tbody th.check-column input[type=checkbox], +.plugins tbody td.check-column input[type=checkbox] { margin-top: 4px; } @@ -1235,7 +1239,8 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before { .plugins thead td.check-column, .plugins tfoot td.check-column, -.plugins .inactive th.check-column { +.plugins .inactive th.check-column, +.plugins .inactive td.check-column { padding-left: 6px; } @@ -1325,6 +1330,7 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before { } .plugins .active th.check-column, +.plugins .active td.check-column, .plugin-update-tr.active td { border-left: 4px solid var(--wp-admin-theme-color); } @@ -1410,7 +1416,8 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before { text-decoration: underline; } -.plugins tr.paused th.check-column { +.plugins tr.paused th.check-column, +.plugins tr.paused td.check-column { border-left: 4px solid #b32d2e; } @@ -1909,7 +1916,8 @@ div.action-links, } .wp-list-table th.column-primary ~ th, - .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column) { + .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column), + .wp-list-table tr:not(.inline-edit-row):not(.no-items) th.column-primary ~ td:not(.check-column) { display: none; } @@ -1918,7 +1926,8 @@ div.action-links, } /* Checkboxes need to show */ - .wp-list-table tr th.check-column { + .wp-list-table tr th.check-column, + .wp-list-table tr td.check-column { display: table-cell; } @@ -1936,11 +1945,13 @@ div.action-links, width: auto !important; /* needs to override some columns that are more specifically targeted */ } - .wp-list-table td.column-primary { + .wp-list-table td.column-primary, + .wp-list-table th.column-primary { padding-right: 50px; /* space for toggle button */ } - .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column) { + .wp-list-table tr:not(.inline-edit-row):not(.no-items) td.column-primary ~ td:not(.check-column), + .wp-list-table tr:not(.inline-edit-row):not(.no-items) th.column-primary ~ td:not(.check-column) { padding: 3px 8px 3px 35%; } @@ -2269,12 +2280,14 @@ div.action-links, } .plugins tr.active + tr.inactive th.check-column, + .plugins tr.active + tr.inactive td.check-column, .plugins tr.active + tr.inactive td.column-description, .plugins .plugin-update-tr:before { box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.1); } .plugins tr.active + tr.inactive th.check-column, + .plugins tr.active + tr.inactive td.check-column, .plugins tr.active + tr.inactive td { border-top: none; } @@ -2309,13 +2322,15 @@ div.action-links, line-height: 1.5; } - .plugins tbody th.check-column { + .plugins tbody th.check-column, + .plugins tbody td.check-column { padding: 8px 0 0 5px; } .plugins thead td.check-column, .plugins tfoot td.check-column, - .plugins .inactive th.check-column { + .plugins .inactive th.check-column, + .plugins .inactive td.check-column { padding-left: 9px; } diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index d32f08a438c60..b78af78abc03e 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1767,6 +1767,26 @@ protected function column_default( $item, $column_name ) {} */ protected function column_cb( $item ) {} + /** + * Returns a clean, human-readable label for the primary column's row header. + * + * Used as the `aria-label` attribute value on the `` element, + * giving screen readers a concise cell name instead of computing it from + * the full cell content (which may include row action links, excerpts, etc.). + * + * Subclasses should override this method to return the item's primary + * identifier (e.g. post title, plugin name, username). Return an empty string + * to omit the attribute. + * + * @since 6.9.0 + * + * @param object|array $item The current item. + * @return string The aria-label value, or an empty string. + */ + protected function get_primary_column_aria_label( $item ) { + return ''; + } + /** * Generates the columns for a single row of the table. * @@ -1796,9 +1816,9 @@ protected function single_row_columns( $item ) { $attributes = "class='$classes' $data"; if ( 'cb' === $column_name ) { - echo ''; + echo ''; echo $this->column_cb( $item ); - echo ''; + echo ''; } elseif ( method_exists( $this, '_column_' . $column_name ) ) { echo call_user_func( array( $this, '_column_' . $column_name ), @@ -1807,16 +1827,29 @@ protected function single_row_columns( $item ) { $data, $primary ); - } elseif ( method_exists( $this, 'column_' . $column_name ) ) { - echo ""; - echo call_user_func( array( $this, 'column_' . $column_name ), $item ); - echo $this->handle_row_actions( $item, $column_name, $primary ); - echo ''; } else { - echo ""; - echo $this->column_default( $item, $column_name ); + $is_primary = ( $primary === $column_name ); + $tag = $is_primary ? 'th' : 'td'; + $scope = $is_primary ? ' scope="row"' : ''; + + $aria_label = ''; + if ( $is_primary ) { + $label = $this->get_primary_column_aria_label( $item ); + if ( '' !== $label ) { + $aria_label = ' aria-label="' . esc_attr( $label ) . '"'; + } + } + + echo "<$tag $attributes$scope$aria_label>"; + + if ( method_exists( $this, 'column_' . $column_name ) ) { + echo call_user_func( array( $this, 'column_' . $column_name ), $item ); + } else { + echo $this->column_default( $item, $column_name ); + } + echo $this->handle_row_actions( $item, $column_name, $primary ); - echo ''; + echo ""; } } } diff --git a/src/wp-admin/includes/class-wp-ms-themes-list-table.php b/src/wp-admin/includes/class-wp-ms-themes-list-table.php index a0fca2fd60fe4..81c35414d9053 100644 --- a/src/wp-admin/includes/class-wp-ms-themes-list-table.php +++ b/src/wp-admin/includes/class-wp-ms-themes-list-table.php @@ -940,11 +940,11 @@ public function single_row_columns( $item ) { switch ( $column_name ) { case 'cb': - echo ''; + echo ''; $this->column_cb( $item ); - echo ''; + echo ''; break; case 'name': @@ -966,11 +966,11 @@ public function single_row_columns( $item ) { } } - echo "" . $item->display( 'Name' ) . $active_theme_label . ''; + echo "" . $item->display( 'Name' ) . $active_theme_label . ''; $this->column_name( $item ); - echo ''; + echo ''; break; case 'description': diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index 08b2e982e702f..d8945e103064e 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -1233,12 +1233,12 @@ public function single_row( $item ) { switch ( $column_name ) { case 'cb': - echo "$checkbox"; + echo "$checkbox"; break; case 'name': - echo "$plugin_name"; + echo "$plugin_name"; echo $this->row_actions( $actions, true ); - echo ''; + echo ''; break; case 'description': $classes = 'column-description desc'; diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index 7522f8561ba44..3795495d6d21a 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -1114,10 +1114,28 @@ public function column_cb( $item ) { * @param string $primary */ protected function _column_title( $post, $classes, $data, $primary ) { - echo ''; + $aria_label = $this->get_primary_column_aria_label( $post ); + $aria_attr = ( '' !== $aria_label ) ? ' aria-label="' . esc_attr( $aria_label ) . '"' : ''; + echo ''; echo $this->column_title( $post ); echo $this->handle_row_actions( $post, 'title', $primary ); - echo ''; + echo ''; + } + + /** + * Returns a clean label for the primary (title) column's row header `aria-label`. + * + * Provides screen readers with just the post title as the row header name, + * preventing them from computing the name from the full cell content + * (which includes row action links, post states, and possibly an excerpt). + * + * @since 6.9.0 + * + * @param WP_Post $item The current post object. + * @return string The post title, or 'no title' if no title. + */ + protected function get_primary_column_aria_label( $item ) { + return isset( $item->post_title ) && ! empty( $item->post_title ) ? $item->post_title : __( 'no title' ); } /** diff --git a/src/wp-admin/includes/class-wp-users-list-table.php b/src/wp-admin/includes/class-wp-users-list-table.php index 9a8709b438e05..dd54b200bafaf 100644 --- a/src/wp-admin/includes/class-wp-users-list-table.php +++ b/src/wp-admin/includes/class-wp-users-list-table.php @@ -563,9 +563,16 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0 $attributes = "class='$classes' $data"; if ( 'cb' === $column_name ) { - $row .= "$checkbox"; + $row .= "$checkbox"; } else { - $row .= ""; + $is_primary = ( $primary === $column_name ); + $tag = $is_primary ? 'th' : 'td'; + $scope = $is_primary ? ' scope="row"' : ''; + $aria_label = ''; + if ( $is_primary ) { + $aria_label = ' aria-label="' . esc_attr( $user_object->user_login ) . '"'; + } + $row .= "<$tag $attributes$scope$aria_label>"; switch ( $column_name ) { case 'username': $row .= "$avatar $edit"; @@ -628,7 +635,8 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0 if ( $primary === $column_name ) { $row .= $this->row_actions( $actions ); } - $row .= ''; + $tag = ( $primary === $column_name ) ? 'th' : 'td'; + $row .= ""; } } $row .= ''; From 2d1c511ed5ea456ad77e5de19a4548a7caa44641 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Thu, 23 Jul 2026 21:41:40 +0000 Subject: [PATCH 208/336] Administration: Update bulk edit validity checks & CSS. Omitted to update the scripting validating bulk edit selections when changing the list table `th`. Add overlooked CSS to set post title `th` to `vertical-align: top`. Follow up to [62838]. Developed in https://github.com/WordPress/wordpress-develop/pull/12666 Props joedolson, tobiasbg. Fixes #32892. git-svn-id: https://develop.svn.wordpress.org/trunk@62839 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/_enqueues/admin/inline-edit-post.js | 4 ++-- src/wp-admin/css/common.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/admin/inline-edit-post.js b/src/js/_enqueues/admin/inline-edit-post.js index 6e9f4e9f20503..36ffaf18ef778 100644 --- a/src/js/_enqueues/admin/inline-edit-post.js +++ b/src/js/_enqueues/admin/inline-edit-post.js @@ -191,7 +191,7 @@ window.wp = window.wp || {}; */ setBulk : function(){ var te = '', type = this.type, c = true; - var checkedPosts = $( 'tbody th.check-column input[type="checkbox"]:checked' ); + var checkedPosts = $( 'tbody .check-column input[type="checkbox"]:checked' ); var categories = {}; this.revert(); @@ -207,7 +207,7 @@ window.wp = window.wp || {}; * * Get the selected posts based on the checked checkboxes in the post table. */ - $( 'tbody th.check-column input[type="checkbox"]' ).each( function() { + $( 'tbody .check-column input[type="checkbox"]' ).each( function() { // If the checkbox for a post is selected, add the post to the edit list. if ( $(this).prop('checked') ) { diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index 88c1c0d6ac0d1..c286b8fa9ae0c 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -501,6 +501,7 @@ code { border-bottom-width: 0; } +.widefat th, .widefat td { vertical-align: top; } From baf6c2bbd6cccab264501d6a0d52bd535dee86fb Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Fri, 24 Jul 2026 02:24:38 +0000 Subject: [PATCH 209/336] Media: Accessibility: Fix labels in scale tool. The field for setting width in the media editor scale inputs had an incorrect label. Additionally, both the width and height labels had extraneous adjectives describing the fields. These are not necessary given the `fieldset` and `legend` providing context. Change the 'width' label from 'scale height' to 'Width'. Change the 'height' label from 'scale height' to 'Height'. Props csmcneill, nilambar, tusharaddweb, khokansardar, mukesh27, joedolson, afercia. Fixes #65685. git-svn-id: https://develop.svn.wordpress.org/trunk@62840 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/image-edit.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index a9ddc55e1bf96..a192ef0000c17 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -151,12 +151,17 @@ function wp_image_editor( $post_id, $msg = false ) { - +
From 58b28ff78834dbec24a29acc2def1753361aadcf Mon Sep 17 00:00:00 2001 From: Andrew Serong Date: Fri, 24 Jul 2026 02:42:23 +0000 Subject: [PATCH 210/336] REST API: Enforce multisite upload limits when sideloading media from a URL. The attachments controller's URL-based creation path, `create_item_from_url()`, passed the downloaded file to `media_handle_sideload()` without running `check_upload_size()`. Unlike the multipart and raw-body upload paths, it did not enforce the multisite maximum file size or the site's upload space quota. Run `check_upload_size()` on the downloaded file before sideloading it, for parity with the other upload paths, and remove the temporary file when the check fails. Developed in: https://github.com/WordPress/wordpress-develop/pull/12670 Follow-up to [62659]. Props andrewserong, ramonopoly. Fixes #65517. git-svn-id: https://develop.svn.wordpress.org/trunk@62841 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-rest-attachments-controller.php | 8 +++ .../rest-api/rest-attachments-controller.php | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 609b133ca4cfc..6e06f1563c50c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -641,6 +641,14 @@ protected function create_item_from_url( $request ) { 'tmp_name' => $tmp_file, ); + $size_check = self::check_upload_size( $file_array ); + if ( is_wp_error( $size_check ) ) { + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + return $size_check; + } + $attachment_id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $attachment_id ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 268ac019c3bc9..90899df850d47 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -5330,6 +5330,70 @@ public function test_create_item_from_url_returns_error_on_download_failure() { $this->assertSame( 500, $response->get_status() ); } + /** + * Verifies that the URL sideload path enforces the multisite maximum file + * size, for parity with the multipart and raw-body upload paths. + * + * @ticket 65517 + * @group multisite + * @group ms-required + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::check_upload_size + */ + public function test_create_item_from_url_exceeds_multisite_max_filesize() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + update_site_option( 'fileupload_maxk', 1 ); + update_site_option( 'upload_space_check_disabled', false ); + + // Ensure ample space is available so the file-size limit is what rejects it. + add_filter( 'pre_get_space_used', '__return_zero' ); + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/too-big.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + + /** + * Verifies that the URL sideload path enforces the multisite site upload + * space quota, for parity with the multipart and raw-body upload paths. + * + * @ticket 65517 + * @group multisite + * @group ms-required + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + * @covers WP_REST_Attachments_Controller::check_upload_size + */ + public function test_create_item_from_url_exceeds_multisite_site_upload_space() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + add_filter( 'get_space_allowed', '__return_zero' ); + update_site_option( 'upload_space_check_disabled', false ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/no-space.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); + } + /** * Verifies that a URL with no usable path bails with a 400 before any * download is attempted, rather than handing an empty filename to the From 53119d1e58a3ab1460c36ca719bf73829275381c Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 24 Jul 2026 12:40:43 +0000 Subject: [PATCH 211/336] Plugins: Remove redundant type casting in `wp_filter_build_unique_id()`. The `(object)` type casting was preceded by an `is_object()` check and can be safely removed. The `isset()` language construct is enough to check for an array when detecting malformed callbacks, so the `(array)` type casting is not required. Removing the type casting results in an additional performance improvement up to ~8% for the function. Follow-up to [62408]. See #58291, #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62842 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/plugin.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index 55459c0dd96c8..f64b584374c8e 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -1005,10 +1005,9 @@ function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string } if ( is_object( $callback ) ) { - return (string) spl_object_id( (object) $callback ); + return (string) spl_object_id( $callback ); } - $callback = (array) $callback; if ( ! isset( $callback[1] ) || ! is_string( $callback[1] ) ) { return null; } From d88c95d9da1123ff6bf5d2042922b3c2cf8615db Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Fri, 24 Jul 2026 15:35:47 +0000 Subject: [PATCH 212/336] Administration: Improve UI when adding or removing tags. Improve AJAX interactions in the user interface when adding or removing tags by exposing the default `No tags found` row and removing bulk actions and search when the last tag is removed, and by showing bulk actions when tags are added, and incrementing item counts when adding or deleting. Developed in https://github.com/WordPress/wordpress-develop/pull/8761 Props sainathpoojary, sirlouen, rishabhwp, yashjawale, wildworks, madhavishah01, khokansardar, joedolson. Fixes #63372. git-svn-id: https://develop.svn.wordpress.org/trunk@62843 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/_enqueues/admin/tags.js | 57 ++++++++++++++++++- src/wp-admin/includes/class-wp-list-table.php | 14 +++-- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/js/_enqueues/admin/tags.js b/src/js/_enqueues/admin/tags.js index ff7761adb8d3e..88e38b6926309 100644 --- a/src/js/_enqueues/admin/tags.js +++ b/src/js/_enqueues/admin/tags.js @@ -59,8 +59,10 @@ jQuery( function($) { nextFocus = prevFocus; } } - - tr.fadeOut('normal', function(){ tr.remove(); }); + tr.fadeOut('normal', function() { + tr.remove(); + updateTableNavCount(); + }); /** * Removes the term from the parent box and the tag cloud. @@ -73,7 +75,7 @@ jQuery( function($) { $('a.tag-link-' + data.match(/tag_ID=(\d+)/)[1]).remove(); nextFocus.trigger( 'focus' ); message = wp.i18n.__( 'The selected tag has been deleted.' ); - + } else if ( '-1' == r ) { message = wp.i18n.__( 'Sorry, you are not allowed to do that.' ); $('#ajax-response').empty().append('

' + message + '

'); @@ -103,6 +105,53 @@ jQuery( function($) { tr.find( ':input, a' ).prop( 'disabled', false ).removeAttr( 'tabindex' ); } + /** + * Updates the item count and table navigation after a tag is added or removed. + * + * Tags are added and removed client-side, but the item count, the `.tablenav` + * regions, the search box, and the empty-state row are otherwise only + * reconciled by PHP on a full page reload. This keeps them in sync. + * + * @param {string} [action] Pass 'add' when a tag was added. Any other value, + * including none, is treated as a removal. + * + * @return {void} + */ + function updateTableNavCount( action ) { + var $displayingNum = $( '.tablenav-pages .displaying-num' ), + currentCount = parseInt( $displayingNum.first().text().replace( /[^0-9]/g, '' ), 10 ) || 0, + itemCount = ( 'add' === action ) ? currentCount + 1 : Math.max( currentCount - 1, 0 ), + formattedCount = itemCount.toLocaleString(); + + $displayingNum.text( + wp.i18n.sprintf( + /* translators: %s: Number of items. */ + wp.i18n._n( '%s item', '%s items', itemCount ), + formattedCount + ) + ); + + if ( itemCount < 1 ) { + // No tags remain: show the empty-state row and hide the navigation. + var $list = $( '#the-list' ); + + if ( ! $list.find( 'tr.no-items' ).length ) { + var colspan = $list.closest( 'table' ).find( 'thead > tr' ).first().children( ':not(.hidden)' ).length; + $list.append( + '' + + wp.i18n.__( 'No tags found.' ) + + '' + ); + } + $( '.tablenav > *' ).hide(); + $( 'p.search-box' ).hide(); + } else { + $( '#the-list' ).find( 'tr.no-items' ).remove(); + $( '.tablenav > *' ).show(); + $( 'p.search-box' ).show(); + } + } + /** * Adds a deletion confirmation when removing a tag. * @@ -192,6 +241,8 @@ jQuery( function($) { } $('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):visible, textarea:visible', form).val(''); + + updateTableNavCount( 'add' ); }); return false; diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index b78af78abc03e..6ebae0e98eb86 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1029,6 +1029,8 @@ protected function get_items_per_page( $option, $default_value = 20 ) { */ protected function pagination( $which ) { if ( empty( $this->_pagination_args['total_items'] ) ) { + // translators: Number is a fixed value. This is default text when no items are found. + echo '
' . __( '0 items' ) . '
'; return; } @@ -1685,12 +1687,16 @@ protected function display_tablenav( $which ) { ?>
- has_items() ) : ?> -
+ has_items() ) { + $visibility = ''; + } + ?> +
bulk_actions( $which ); ?>
- extra_tablenav( $which ); $this->pagination( $which ); ?> From 5830b7cfcaa698f5136ffa0b19e8b555d800eef1 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Fri, 24 Jul 2026 15:47:25 +0000 Subject: [PATCH 213/336] Privacy: More accurate admin notices when saving. When saving Privacy Policy page settings, show a notice that indicates there is no Privacy Policy page set instead of "Privacy Policy page updated successfully" if no page is selected. Differentiate between removing the current page and saving settings with no changes. Developed in https://github.com/WordPress/wordpress-develop/pull/12247 Props anveshika, audrasjb, masteradhoc, micahele, adrianduffell, pedrofigueroa1989, joedolson. Fixes #59276. git-svn-id: https://develop.svn.wordpress.org/trunk@62844 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/options-privacy.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/options-privacy.php b/src/wp-admin/options-privacy.php index 4205967acb3a8..739c8edab1cda 100644 --- a/src/wp-admin/options-privacy.php +++ b/src/wp-admin/options-privacy.php @@ -51,12 +51,15 @@ static function ( $body_class ) { check_admin_referer( $action ); if ( 'set-privacy-page' === $action ) { - $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0; + $previous_privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' ); + $privacy_policy_page_id = isset( $_POST['page_for_privacy_policy'] ) ? (int) $_POST['page_for_privacy_policy'] : 0; update_option( 'wp_page_for_privacy_policy', $privacy_policy_page_id ); - $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' ); + $privacy_page_message_type = 'success'; if ( $privacy_policy_page_id ) { + $privacy_page_updated_message = __( 'Privacy Policy page updated successfully.' ); + /* * Don't always link to the menu customizer: * @@ -75,9 +78,16 @@ static function ( $body_class ) { esc_url( add_query_arg( 'autofocus[panel]', 'nav_menus', admin_url( 'customize.php' ) ) ) ); } + } elseif ( $previous_privacy_policy_page_id ) { + // A previously set Privacy Policy page was cleared. + $privacy_page_updated_message = __( 'Privacy Policy page removed.' ); + } else { + // No Privacy Policy page was set before, and none is set now. + $privacy_page_updated_message = __( 'No Privacy Policy page is currently set.' ); + $privacy_page_message_type = 'info'; } - add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, 'success' ); + add_settings_error( 'page_for_privacy_policy', 'page_for_privacy_policy', $privacy_page_updated_message, $privacy_page_message_type ); } elseif ( 'create-privacy-page' === $action ) { if ( ! class_exists( 'WP_Privacy_Policy_Content' ) ) { From 6d1d03da1ec1266e5681e5cd8e36275798e728bb Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Fri, 24 Jul 2026 18:57:29 +0000 Subject: [PATCH 214/336] Privacy: Delete Privacy Policy setting when page deleted. If the privacy page is deleted but the setting is left intact, an admin_hook would fire on every admin screen attempting to notify about changes in the privacy policy. With the page deleted, this database read is never cached, since it returns no results. Add a `before_delete_post` hook to reset setting if the post is deleted. Add a guard to reset the option on the privacy screen to cover edge cases. Developed in https://github.com/WordPress/wordpress-develop/pull/11443, https://github.com/WordPress/wordpress-develop/pull/11520 Props johnjamesjacoby, masteradhoc, westonruter, nimeshatxecurify, mukesh27, joedolson. Fixes #56694. git-svn-id: https://develop.svn.wordpress.org/trunk@62845 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-privacy-policy-content.php | 6 + src/wp-includes/default-filters.php | 1 + src/wp-includes/post.php | 16 ++ .../wpPrivacyResetPolicyPageForPost.php | 172 ++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php diff --git a/src/wp-admin/includes/class-wp-privacy-policy-content.php b/src/wp-admin/includes/class-wp-privacy-policy-content.php index 2f7ec2108d22f..141f073cd260c 100644 --- a/src/wp-admin/includes/class-wp-privacy-policy-content.php +++ b/src/wp-admin/includes/class-wp-privacy-policy-content.php @@ -329,6 +329,12 @@ public static function notice( $post = null ) { $current_screen = get_current_screen(); $policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' ); + // If the privacy policy page has been deleted, reset the option and bail. + if ( $policy_page_id && ! get_post( $policy_page_id ) ) { + update_option( 'wp_page_for_privacy_policy', 0 ); + return; + } + if ( 'post' !== $current_screen->base || $policy_page_id !== $post->ID ) { return; } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index b2790c7d43ec9..66504d37ad84d 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -590,6 +590,7 @@ add_action( 'init', 'create_initial_post_types', 0 ); // Highest priority. add_action( 'admin_menu', '_add_post_type_submenus' ); add_action( 'before_delete_post', '_reset_front_page_settings_for_post' ); +add_action( 'before_delete_post', '_reset_privacy_policy_page_for_post' ); add_action( 'wp_trash_post', '_reset_front_page_settings_for_post' ); add_action( 'change_locale', 'create_initial_post_types' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 3813176140bb4..da3abfbd7d61c 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -4052,6 +4052,22 @@ function _reset_front_page_settings_for_post( $post_id ) { unstick_post( $post->ID ); } +/** + * Resets the Privacy Policy page ID option when the Privacy Policy page + * is permanently deleted, to prevent uncached database queries for a + * non-existent page. + * + * @since 7.1.0 + * @access private + * + * @param int $post_id The ID of the post being deleted. + */ +function _reset_privacy_policy_page_for_post( int $post_id ): void { + if ( 'page' === get_post_type( $post_id ) && ( (int) get_option( 'wp_page_for_privacy_policy' ) === $post_id ) ) { + update_option( 'wp_page_for_privacy_policy', 0 ); + } +} + /** * Moves a post or page to the Trash * diff --git a/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php new file mode 100644 index 0000000000000..50ce04cb1bd44 --- /dev/null +++ b/tests/phpunit/tests/privacy/wpPrivacyResetPolicyPageForPost.php @@ -0,0 +1,172 @@ +post->create( array( 'post_type' => 'page' ) ); + assert( is_int( $page_id ) ); + $this->policy_page_id = $page_id; + update_option( 'wp_page_for_privacy_policy', $this->policy_page_id ); + } + + public function tear_down(): void { + delete_option( 'wp_page_for_privacy_policy' ); + parent::tear_down(); + } + + /** + * Tests that trashing the Privacy Policy page does NOT reset the option, + * so that restoring from trash preserves the assignment. + * + * @ticket 56694 + */ + public function test_trashing_privacy_policy_page_does_not_reset_option(): void { + wp_trash_post( $this->policy_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Trashing the Privacy Policy page should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that permanently deleting the Privacy Policy page resets the option to 0. + * + * @ticket 56694 + */ + public function test_deleting_privacy_policy_page_resets_option(): void { + wp_delete_post( $this->policy_page_id, true ); + + $this->assertSame( 0, (int) get_option( 'wp_page_for_privacy_policy' ) ); + } + + /** + * Tests that trashing a different page does not change the option. + * + * @ticket 56694 + */ + public function test_trashing_a_different_page_does_not_reset_option(): void { + $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + $this->assertIsInt( $other_page_id ); + wp_trash_post( $other_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Trashing an unrelated page should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that deleting a non-page post type does not change the option. + * + * @ticket 56694 + */ + public function test_deleting_non_page_post_type_does_not_reset_option(): void { + $post_id = self::factory()->post->create( array( 'post_type' => 'post' ) ); + $this->assertIsInt( $post_id ); + wp_delete_post( $post_id, true ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Deleting a non-page post should not reset wp_page_for_privacy_policy.' + ); + } + + /** + * Tests that WP_Privacy_Policy_Content::notice() resets the option to 0 + * when the stored ID points to a page that no longer exists. + * + * @ticket 56694 + * + * @covers WP_Privacy_Policy_Content::notice + */ + public function test_notice_self_heals_when_policy_page_does_not_exist(): void { + require_once ABSPATH . 'wp-admin/includes/class-wp-privacy-policy-content.php'; + + update_option( 'wp_page_for_privacy_policy', 99999 ); + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->assertIsInt( $user_id ); + wp_set_current_user( $user_id ); + if ( is_multisite() ) { + grant_super_admin( $user_id ); + } + set_current_screen( 'post' ); + + $post = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ); + $this->assertInstanceOf( WP_Post::class, $post ); + WP_Privacy_Policy_Content::notice( $post ); + + $this->assertSame( + 0, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'notice() should reset the option to 0 when the stored page does not exist.' + ); + } + + /** + * Tests that _reset_privacy_policy_page_for_post() does not call + * update_option() when wp_page_for_privacy_policy is already 0. + * + * @ticket 56694 + */ + public function test_no_update_option_when_policy_page_already_zero(): void { + update_option( 'wp_page_for_privacy_policy', 0 ); + + $call_count = 0; + add_filter( + 'pre_update_option_wp_page_for_privacy_policy', + static function ( $value ) use ( &$call_count ) { + ++$call_count; + return $value; + } + ); + + $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + $this->assertIsInt( $other_page_id ); + wp_delete_post( $other_page_id, true ); + + $this->assertSame( + 0, + $call_count, + 'update_option() should not be called when wp_page_for_privacy_policy is already 0.' + ); + } + + /** + * Tests that untrashing the Privacy Policy page preserves the option, + * confirming the trash/restore cycle keeps the assignment intact. + * + * @ticket 56694 + */ + public function test_untrashing_privacy_policy_page_preserves_option(): void { + wp_trash_post( $this->policy_page_id ); + wp_untrash_post( $this->policy_page_id ); + + $this->assertSame( + $this->policy_page_id, + (int) get_option( 'wp_page_for_privacy_policy' ), + 'Untrashing the Privacy Policy page should preserve wp_page_for_privacy_policy.' + ); + } +} From c6d10e9441880426be4330e75457507c7ef40477 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Fri, 24 Jul 2026 22:12:57 +0000 Subject: [PATCH 215/336] Editor: Sync the REST index preload field list with core-data. Sync the REST index preload field list in the post and site editors with the list the client requests, fixing a mismatch that left the preloaded response unused and logged a console warning on every editor load. Follow-up to [61703], [62806]. Props wildworks, westonruter. Fixes #65699. git-svn-id: https://develop.svn.wordpress.org/trunk@62846 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/edit-form-blocks.php | 14 ++++++++------ src/wp-admin/site-editor.php | 12 +++++++----- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index 44fd623fa5ad2..a6d198ff15343 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -85,19 +85,21 @@ static function ( $classes ) { '/wp/v2/global-styles/' . WP_Theme_JSON_Resolver::get_user_global_styles_post_id() . '?context=' . $global_styles_endpoint_context, // Used by getBlockPatternCategories in useBlockEditorSettings. '/wp/v2/block-patterns/categories', - // @see packages/core-data/src/entities.js + /** + * The preloaded URL must exactly match the request the client makes, + * including the field order. + * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entities.js + */ '/?_fields=' . implode( ',', array( 'description', 'gmt_offset', 'home', + 'image_max_bit_depth', 'image_sizes', 'image_size_threshold', - 'image_output_formats', - 'jpeg_interlaced', - 'png_interlaced', - 'gif_interlaced', + 'image_strip_meta', 'name', 'site_icon', 'site_icon_url', @@ -109,7 +111,7 @@ static function ( $classes ) { 'show_on_front', ) ), - $paths[] = add_query_arg( + add_query_arg( 'slug', // @link https://github.com/WordPress/gutenberg/blob/e093fefd041eb6cc4a4e7f67b92ab54fd75c8858/packages/core-data/src/private-selectors.ts#L244-L254 $template_lookup_slug, diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 9a8268c3392d7..4289f89f7102c 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -211,19 +211,21 @@ static function ( $classes ) { array( '/wp/v2/settings', 'OPTIONS' ), // Used by getBlockPatternCategories in useBlockEditorSettings. '/wp/v2/block-patterns/categories', - // @see packages/core-data/src/entities.js + /** + * The preloaded URL must exactly match the request the client makes, + * including the field order. + * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entities.js + */ '/?_fields=' . implode( ',', array( 'description', 'gmt_offset', 'home', + 'image_max_bit_depth', 'image_sizes', 'image_size_threshold', - 'image_output_formats', - 'jpeg_interlaced', - 'png_interlaced', - 'gif_interlaced', + 'image_strip_meta', 'name', 'site_icon', 'site_icon_url', From 749e622f7380686b71e9c9610161b38f8f7a6458 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Fri, 24 Jul 2026 22:31:58 +0000 Subject: [PATCH 216/336] Privacy: Fix type inconsistencies in data erasure inline notices. Additional messages can be inserted by plugins using the `wp_privacy_personal_data_erasers` filter, and are generated as list items (`li`) inside the notice markup. However, `li` was not targeted with notice-specific styling, and inherited the list item styles from the containing table. Add additional styles targeting list items inside notices in list tables to equalize font sizes and styling. Developed in https://github.com/WordPress/wordpress-develop/pull/12021 Props kimannwall, masteradhoc, joedolson. Fixes #53611. git-svn-id: https://develop.svn.wordpress.org/trunk@62847 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index c286b8fa9ae0c..e1e5ee3181330 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -523,6 +523,13 @@ code { font-size: 14px; } +.widefat td .notice ul li, +.widefat th .notice ul li { + font-size: 13px; + line-height: 1.54; + margin: 0.5em 0; +} + .widefat td.check-column input, .widefat th input, .updates-table td input, From 3300b8b51acdcd53df7dd5f2cbabe6593a475cf1 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 25 Jul 2026 02:37:01 +0000 Subject: [PATCH 217/336] Administration: Make the Events widget no-JS notice translatable. The no-JavaScript fallback notice in `wp_print_community_events_markup()` wrapped its string in bare parentheses instead of `__()`, so it was never translated. Wrap it in `__()` to match the surrounding notices. Follow-up to [56599]. Props hbhalodia, mukesh27, wildworks. Fixes #65705. git-svn-id: https://develop.svn.wordpress.org/trunk@62848 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/dashboard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index a0b0ac6c77239..e74c31750513d 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1377,7 +1377,7 @@ function wp_dashboard_events_news() { * @since 4.8.0 */ function wp_print_community_events_markup() { - $community_events_notice = '

' . ( 'This widget requires JavaScript.' ) . '

'; + $community_events_notice = '

' . __( 'This widget requires JavaScript.' ) . '

'; $community_events_notice .= ''; $community_events_notice .= ''; From 07b1f8b1d25db182d1ac4c2529d97e3d0cb04aea Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 25 Jul 2026 20:43:21 +0000 Subject: [PATCH 218/336] Docs: Fix typo in a comment in `wp_dashboard_rss_control()`. Follow-up to [6705]. Props mukesh27. See #64896. git-svn-id: https://develop.svn.wordpress.org/trunk@62849 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/dashboard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index e74c31750513d..5fdbaf7a4fa40 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1294,7 +1294,7 @@ function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) { $widget_options[ $widget_id ] = wp_widget_rss_process( $_POST['widget-rss'][ $number ] ); $widget_options[ $widget_id ]['number'] = $number; - // Title is optional. If black, fill it if possible. + // Title is optional. If blank, fill it if possible. if ( ! $widget_options[ $widget_id ]['title'] && isset( $_POST['widget-rss'][ $number ]['title'] ) ) { $rss = fetch_feed( $widget_options[ $widget_id ]['url'] ); if ( is_wp_error( $rss ) ) { From 05d3b3cec4e761cbc0dc9b39d37116af2397950e Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Sun, 26 Jul 2026 15:04:51 +0000 Subject: [PATCH 219/336] KSES: Allow the autofocus attribute on dialog elements. First step to allow the usage of the autofocus attribute for native dialog elements. More work will follow to add a context-aware mechanism to KSES and allow the attribute on dialog element children. Props westonruter, joedolson, mukesh27, afercia. Fixes #65491. git-svn-id: https://develop.svn.wordpress.org/trunk@62850 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/kses.php | 7 ++++--- tests/phpunit/tests/kses.php | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 46cd2c4576c03..d68021c3a8b30 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -158,9 +158,10 @@ 'popover' => true, ), 'dialog' => array( - 'closedby' => true, - 'open' => true, - 'popover' => true, + 'closedby' => true, + 'open' => true, + 'popover' => true, + 'autofocus' => true, ), 'dl' => array(), 'dt' => array(), diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 1afd7e0884a64..f560d88403524 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -2183,6 +2183,18 @@ public function test_wp_kses_main_tag_standard_attributes() { $this->assertEqualHTML( $html, wp_kses_post( $html ) ); } + /** + * Tests that the autofocus attribute is allowed on dialog elements and removed from other focusable elements. + * + * @ticket 65491 + */ + public function test_wp_kses_dialog_autofocus_attribute() { + $html = 'Content
Some content
'; + $expected = 'Content
Some content
'; + + $this->assertEqualHTML( $expected, wp_kses_post( $html ) ); + } + /** * Test that Invoker Commands API attributes are preserved on buttons in post content. * From 17d5f5f09e9d78f6ef65355206c50601dcf637ae Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Sun, 26 Jul 2026 20:53:31 +0000 Subject: [PATCH 220/336] Media: Restore the label of the 'Filter by date' select in the Media grid. Fixes a typo after [62326] that prevented the 'Filter by date' select label from rendering. Props joedolson, mirmpro, afercia. Fixes #65711. git-svn-id: https://develop.svn.wordpress.org/trunk@62851 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/media/views/attachments/browser.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index 26218ea2fa1ae..5533110d815f9 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -223,7 +223,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.toolbar.set( 'filters', Filters.render() ); } } - + /* * Feels odd to bring the global media library switcher into the Attachment browser view. * Is this a use case for doAction( 'add:toolbar-items:attachments-browser', this.toolbar ); @@ -241,7 +241,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro }).render() ); // DateFilter is a + +

diff --git a/src/wp-admin/options-reading.php b/src/wp-admin/options-reading.php index 31facac7edcca..d52d51bbe3ae4 100644 --- a/src/wp-admin/options-reading.php +++ b/src/wp-admin/options-reading.php @@ -175,14 +175,14 @@ - + - + - - + + From 455fb3f01faa13a53a37d93ba2cfc350b2809495 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Sat, 1 Aug 2026 09:10:32 +0000 Subject: [PATCH 286/336] Media: Restore selection previews in the Media dialog bottom toolbar. Developed in https://github.com/WordPress/wordpress-develop/pull/12784 Props sukhendu2002, mukesh27, iqbal1hossain, afercia. Fixes #65767. git-svn-id: https://develop.svn.wordpress.org/trunk@62962 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/css/media-views.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 089baaed6c7ab..227be604f7852 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -332,7 +332,6 @@ .media-toolbar-secondary { float: left; height: 100%; - position: relative; display: grid; grid-template-columns: repeat( 2, 1fr ); grid-template-rows: repeat( 2, 1fr ); @@ -1309,6 +1308,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { .attachments-browser .media-toolbar-secondary { max-width: 66%; + position: relative; } .uploader-inline .close { From 1606cb0cc05ab4c3da33db6f3f49b5ec885d5846 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 1 Aug 2026 12:29:43 +0000 Subject: [PATCH 287/336] Media: Fix a jQuery Migrate warning for disabled buttons. Under jQuery 4.0, disabling a media button or the image cropper's action button triggered a jQuery Migrate warning about the boolean `disabled` attribute. These buttons now toggle the `disabled` property instead, which removes the warning and keeps the behavior unchanged. Developed in: https://github.com/WordPress/wordpress-develop/pull/10661 Props audrasjb, azaozz, hbhalodia, neo2k23, wildworks. See #64425. git-svn-id: https://develop.svn.wordpress.org/trunk@62963 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/media/controllers/cropper.js | 2 +- src/js/media/views/button.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/media/controllers/cropper.js b/src/js/media/controllers/cropper.js index b0a7a394400e5..2685f743ea8c7 100644 --- a/src/js/media/controllers/cropper.js +++ b/src/js/media/controllers/cropper.js @@ -116,7 +116,7 @@ Cropper = wp.media.controller.State.extend(/** @lends wp.media.controller.Croppe selection.set({cropDetails: controller.state().imgSelect.getSelection()}); this.$el.text(l10n.cropping); - this.$el.attr('disabled', true); + this.$el.prop( 'disabled', true ); controller.state().doCrop( selection ).done( function( croppedImage ) { controller.trigger('cropped', croppedImage ); diff --git a/src/js/media/views/button.js b/src/js/media/views/button.js index 988c95ccb1bf3..5b380d13d19f0 100644 --- a/src/js/media/views/button.js +++ b/src/js/media/views/button.js @@ -64,7 +64,7 @@ var Button = wp.media.View.extend(/** @lends wp.media.view.Button.prototype */{ classes = _.uniq( classes.concat( this.options.classes ) ); this.el.className = classes.join(' '); - this.$el.attr( 'disabled', model.disabled ); + this.$el.prop( 'disabled', model.disabled ); this.$el.text( this.model.get('text') ); return this; From f408760d5767018c2cf495fe2375e562f4942013 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 1 Aug 2026 16:42:13 +0000 Subject: [PATCH 288/336] HTML API: Use `str_contains()` instead of `strpos()` in `WP_HTML_Tag_Processor`. The `str_contains()` function was introduced in PHP 8.0 and a polyfill is available in WordPress Core, making the intent of the check clearer than comparing the result of `strpos()` against false. Follow-up to [62687]. Props Soean, mukesh27, westonruter. See #64897. git-svn-id: https://develop.svn.wordpress.org/trunk@62964 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 +- 1 file changed, 1 insertion(+), 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 ba33bea28506c..7ca5191a0f162 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 @@ -2073,7 +2073,7 @@ private function parse_next_tag(): bool { */ $is_valid_pi = ( 0 !== $target_length && - false !== strpos( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) && + str_contains( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) && ! ( 3 === $target_length && 0 === substr_compare( $html, 'xml', $target_at, 3, true ) ) && ! ( 14 === $target_length && 0 === substr_compare( $html, 'xml-stylesheet', $target_at, 14, true ) ) ); From 47a5084d3fcffc10db2a69a57c69f9dc948f32ac Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 2 Aug 2026 06:51:36 +0000 Subject: [PATCH 289/336] Build/Test Tools: Improve the constants defined for PHPStan. Many constants were defined as empty strings, including ones that never hold an empty value in a real install. Realistic values are provided for those, matching what wp-settings.php and default-constants.php would produce, so that functions building on them can be given narrower types without the placeholder itself violating them. Constants core itself defines as empty, such as WP_DEVELOPMENT_MODE and COOKIE_DOMAIN, are left as they were. Developed as subset of https://github.com/WordPress/wordpress-develop/pull/11851. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62965 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpstan/bootstrap.php | 70 +++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/tests/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index 6eedeec93c4a7..db0b05e9ed880 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -7,7 +7,14 @@ * Loaded as a `bootstrapFile` by PHPStan; see `base.neon`. */ -// wp_initial_constants() +/* + * A fixed, fictional path rather than the real checkout location. PHPStan resolves + * no files through this constant, and deriving it from __DIR__ embeds the developer's + * own path in error messages, making output differ between machines. + */ +define( 'ABSPATH', '/var/www/html/' ); + +/** @see wp_initial_constants() */ define( 'KB_IN_BYTES', 1024 ); define( 'MB_IN_BYTES', 1024 * KB_IN_BYTES ); define( 'GB_IN_BYTES', 1024 * MB_IN_BYTES ); @@ -16,9 +23,10 @@ define( 'EB_IN_BYTES', 1024 * PB_IN_BYTES ); define( 'ZB_IN_BYTES', 1024 * EB_IN_BYTES ); define( 'YB_IN_BYTES', 1024 * ZB_IN_BYTES ); -define( 'WP_START_TIMESTAMP', microtime( true ) ); -define( 'WP_MEMORY_LIMIT', '' ); -define( 'WP_MAX_MEMORY_LIMIT', '' ); +define( 'WP_START_TIMESTAMP', 1700000000.0 ); // Fixed rather than microtime( true ), whose value would differ on every run. +define( 'WP_MEMORY_LIMIT', '40M' ); +define( 'WP_MAX_MEMORY_LIMIT', '256M' ); +define( 'WP_CONTENT_DIR', ABSPATH . 'wp-content' ); define( 'WP_DEVELOPMENT_MODE', '' ); define( 'WP_DEBUG', false ); define( 'WP_DEBUG_DISPLAY', false ); @@ -35,25 +43,25 @@ define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS ); define( 'YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS ); -// wp_set_lang_dir() -define( 'WP_LANG_DIR', '' ); +/** @see wp_set_lang_dir() */ +define( 'WP_LANG_DIR', WP_CONTENT_DIR . '/languages' ); // wp_plugin_directory_constants() -define( 'WP_CONTENT_URL', '' ); -define( 'WP_PLUGIN_DIR', '' ); -define( 'WP_PLUGIN_URL', '' ); -define( 'PLUGINDIR', '' ); -define( 'WPMU_PLUGIN_DIR', '' ); -define( 'WPMU_PLUGIN_URL', '' ); -define( 'MUPLUGINDIR', '' ); +define( 'WP_CONTENT_URL', 'https://example.com/wp-content' ); +define( 'WP_PLUGIN_DIR', WP_CONTENT_DIR . '/plugins' ); +define( 'WP_PLUGIN_URL', WP_CONTENT_URL . '/plugins' ); +define( 'PLUGINDIR', 'wp-content/plugins' ); +define( 'WPMU_PLUGIN_DIR', WP_CONTENT_DIR . '/mu-plugins' ); +define( 'WPMU_PLUGIN_URL', WP_CONTENT_URL . '/mu-plugins' ); +define( 'MUPLUGINDIR', 'wp-content/mu-plugins' ); -// ms_cookie_constants() +/** @see ms_cookie_constants() */ define( 'COOKIEPATH', '' ); define( 'SITECOOKIEPATH', '' ); define( 'ADMIN_COOKIE_PATH', '' ); define( 'COOKIE_DOMAIN', '' ); -// wp_cookie_constants() +/** @see wp_cookie_constants() */ define( 'COOKIEHASH', '' ); define( 'USER_COOKIE', '' ); define( 'PASS_COOKIE', '' ); @@ -64,34 +72,34 @@ define( 'PLUGINS_COOKIE_PATH', '' ); define( 'RECOVERY_MODE_COOKIE', '' ); -// wp_ssl_constants() +/** @see wp_ssl_constants() */ define( 'FORCE_SSL_LOGIN', false ); define( 'FORCE_SSL_ADMIN', false ); -// wp_functionality_constants() +/** @see wp_functionality_constants() */ define( 'AUTOSAVE_INTERVAL', MINUTE_IN_SECONDS ); define( 'EMPTY_TRASH_DAYS', 1 ); define( 'WP_POST_REVISIONS', true ); define( 'WP_CRON_LOCK_TIMEOUT', MINUTE_IN_SECONDS ); -// wp_templating_constants() -define( 'TEMPLATEPATH', '' ); -define( 'STYLESHEETPATH', '' ); -define( 'WP_DEFAULT_THEME', '' ); +/** @see wp_templating_constants() */ +define( 'TEMPLATEPATH', WP_CONTENT_DIR . '/themes/twentytwentyfive' ); +define( 'STYLESHEETPATH', WP_CONTENT_DIR . '/themes/twentytwentyfive' ); +define( 'WP_DEFAULT_THEME', 'twentytwentyfive' ); -// ms_file_constants() +/** @see ms_file_constants() */ define( 'WPMU_SENDFILE', false ); define( 'WPMU_ACCEL_REDIRECT', false ); -// ms_load_current_site_and_network() +/** @see ms_load_current_site_and_network() */ define( 'NOBLOGREDIRECT', '' ); -// ms_upload_constants() -define( 'UPLOADBLOGSDIR', '' ); -define( 'BLOGUPLOADDIR', '' ); +/** @see ms_upload_constants() */ +define( 'UPLOADBLOGSDIR', 'wp-content/blogs.dir' ); +define( 'BLOGUPLOADDIR', WP_CONTENT_DIR . '/blogs.dir/1/files/' ); -// Misc constants not part of the default lifecycle. -define( 'FS_CONNECT_TIMEOUT', 1 ); -define( 'FS_TIMEOUT', 1 ); -define( 'FS_CHMOD_DIR', 1 ); -define( 'FS_CHMOD_FILE', 1 ); +/** @see WP_Filesystem() */ +define( 'FS_CONNECT_TIMEOUT', 30 ); // 30 seconds. +define( 'FS_TIMEOUT', 30 ); // 30 seconds. +define( 'FS_CHMOD_DIR', 0755 ); +define( 'FS_CHMOD_FILE', 0644 ); From 29fe42e5fb586b0131f1ea821c478ca6d3a521be Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 2 Aug 2026 07:05:51 +0000 Subject: [PATCH 290/336] Docs: Improve block asset registration docblocks. Per the inline documentation standards, a docblock's summary belongs on its own line separated from the description, and the description should not open with "It". This is applied to `register_block_script_module_id()`, `register_block_script_handle()`, and `register_block_style_handle()`, together with some missing articles in the same descriptions. Two of those descriptions no longer matched the code. `register_block_script_handle()` said the script is registered under an automatically generated handle, but since 6.5.0 the handle is taken from the asset file whenever one provides it, and generation is only the fallback. `register_block_style_handle()` said it returns the unprocessed style handle otherwise, which does not hold for the first style of a core block: that one is registered from the block's own stylesheet when separate core block assets are loaded, and skipped entirely when they are not. The same functions gain `@phpstan-` annotations describing the shape of the `$metadata` they accept and the narrower strings they return. The shapes follow the `block.json` schema, which constrains only `name`, so the remaining fields stay plain strings; `file` is nullable and `name` optional because `register_block_type_from_metadata()` can reach all three functions with neither present. Developed in https://github.com/WordPress/wordpress-develop/pull/11851. Follow-up to r48141, r55447, r57559, r57565. Props deepakrohilla, westonruter, sabernhardt, wildworks, audrasjb. See #64898. Fixes #65259. git-svn-id: https://develop.svn.wordpress.org/trunk@62966 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/blocks.php | 86 ++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 41e11f4a2a75f..a0360ffdc8bf8 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -43,6 +43,11 @@ function remove_block_asset_path_prefix( $asset_handle_or_path ) { * @param int $index Optional. Index of the asset when multiple items passed. * Default 0. * @return string Generated asset name for the block's field. + * + * @phpstan-param non-falsy-string $block_name + * @phpstan-param 'editorScript'|'editorStyle'|'script'|'style'|'viewScript'|'viewScriptModule'|'viewStyle' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string */ function generate_block_asset_handle( $block_name, $field_name, $index = 0 ) { if ( str_starts_with( $block_name, 'core/' ) ) { @@ -86,6 +91,8 @@ function generate_block_asset_handle( $block_name, $field_name, $index = 0 ) { * * @param string $path A normalized path to a block asset. * @return string|false The URL to the block asset or false on failure. + * + * @phpstan-return non-falsy-string|false */ function get_block_asset_url( $path ) { if ( empty( $path ) ) { @@ -102,6 +109,7 @@ function get_block_asset_url( $path ) { return includes_url( str_replace( $wpinc_path_norm, '', $path ) ); } + /** @var array $template_paths_norm */ static $template_paths_norm = array(); $template = get_template(); @@ -128,11 +136,12 @@ function get_block_asset_url( $path ) { } /** - * Finds a script module ID for the selected block metadata field. It detects - * when a path to file was provided and optionally finds a corresponding asset - * file with details necessary to register the script module under with an - * automatically generated module ID. It returns unprocessed script module - * ID otherwise. + * Finds a script module ID for the selected block metadata field. + * + * Detects when a path to a file was provided and optionally finds a + * corresponding asset file with details necessary to register the script + * module with an automatically generated module ID. It returns the + * unprocessed script module ID otherwise. * * @since 6.5.0 * @@ -141,6 +150,21 @@ function get_block_asset_url( $path ) { * @param int $index Optional. Index of the script module ID to register when multiple * items passed. Default 0. * @return string|false Script module ID or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * supports?: array{ + * interactivity?: bool|array{interactive?: bool, clientNavigation?: bool, ...}, + * ... + * }, + * viewScriptModule?: string|list, + * ... + * } $metadata + * @phpstan-param 'viewScriptModule' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -170,6 +194,7 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { $module_path_norm = wp_normalize_path( realpath( $path . '/' . $module_path ) ); $module_uri = get_block_asset_url( $module_path_norm ); + /** @var array{ dependencies?: list, version?: string|false|null, ... } $module_asset */ $module_asset = ! empty( $module_asset_path ) ? require $module_asset_path : array(); $module_dependencies = $module_asset['dependencies'] ?? array(); $block_version = $metadata['version'] ?? false; @@ -206,10 +231,13 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { } /** - * Finds a script handle for the selected block metadata field. It detects - * when a path to file was provided and optionally finds a corresponding asset - * file with details necessary to register the script under automatically - * generated handle name. It returns unprocessed script handle otherwise. + * Finds a script handle for the selected block metadata field. + * + * Detects when a path to a file was provided and optionally finds a + * corresponding asset file with details necessary to register the script. The + * handle is taken from the asset file when it provides one, and is otherwise + * generated automatically. It returns the unprocessed script handle when a + * handle rather than a path was given. * * @since 5.5.0 * @since 6.1.0 Added `$index` parameter. @@ -221,6 +249,20 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { * Default 0. * @return string|false Script handle provided directly or created through * script's registration, or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * textdomain?: string, + * editorScript?: string|list, + * script?: string|list, + * viewScript?: string|list, + * ... + * } $metadata + * @phpstan-param 'editorScript'|'script'|'viewScript' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -247,6 +289,7 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { ); // Asset file for blocks is optional. See https://core.trac.wordpress.org/ticket/60460. + /** @var array{ handle?: non-falsy-string, dependencies?: list, version?: string|false|null, ... } $script_asset */ $script_asset = ! empty( $script_asset_path ) ? require $script_asset_path : array(); $script_handle = $script_asset['handle'] ?? generate_block_asset_handle( $metadata['name'], $field_name, $index ); @@ -283,9 +326,13 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { } /** - * Finds a style handle for the block metadata field. It detects when a path - * to file was provided and registers the style under automatically - * generated handle name. It returns unprocessed style handle otherwise. + * Finds a style handle for the block metadata field. + * + * Detects when a path to a file was provided and registers the style under an + * automatically generated handle name. It returns the unprocessed style handle + * otherwise, except for the first style of a core block, which is instead + * registered from the block's own stylesheet when separate core block assets + * are loaded. Core blocks accept only handles, not paths. * * @since 5.5.0 * @since 6.1.0 Added `$index` parameter. @@ -296,6 +343,19 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { * Default 0. * @return string|false Style handle provided directly or created through * style's registration, or false on failure. + * + * @phpstan-param array{ + * name?: non-falsy-string, + * file: non-falsy-string|null, + * version?: string, + * editorStyle?: string|list, + * style?: string|list, + * viewStyle?: string|list, + * ... + * } $metadata + * @phpstan-param 'editorStyle'|'style'|'viewStyle' $field_name + * @phpstan-param int<0, max> $index + * @phpstan-return non-falsy-string|false */ function register_block_style_handle( $metadata, $field_name, $index = 0 ) { if ( empty( $metadata[ $field_name ] ) ) { @@ -2778,7 +2838,7 @@ function build_query_vars_from_query_block( $block, $page ) { if ( 'only' === $block->context['query']['sticky'] ) { /* * Passing an empty array to post__in will return have_posts() as true (and all posts will be returned). - * Logic should be used before hand to determine if WP_Query should be used in the event that the array + * Logic should be used beforehand to determine if WP_Query should be used in the event that the array * being passed to post__in is empty. * * @see https://core.trac.wordpress.org/ticket/28099 From 89685c5790c29aa1a0e618e51a708c505c0c022a Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Sun, 2 Aug 2026 21:59:17 +0000 Subject: [PATCH 291/336] Users: Show/hide password button icon misaligned. In several contexts (Add New User, Install, and Setup/Config), the show/hide password icon was misaligned in varying amounts in different viewports. Add the classes `wp-hide-pw` and `user-new-password-toggle` to the button container to re-use existing CSS consistently. Developed in https://github.com/WordPress/wordpress-develop/pull/12472 Props sanayasir, iamchitti, softglaze, shailu25, soyebsalar01, noruzzaman, ugyensupport, wildworks, ankitpatel1578, praful2111, joedolson, sabernhardt. Fixes #65605. git-svn-id: https://develop.svn.wordpress.org/trunk@62967 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/install.php | 2 +- src/wp-admin/setup-config.php | 2 +- src/wp-admin/user-new.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/install.php b/src/wp-admin/install.php index 737d1b73f1855..b6b7a08f2a5aa 100644 --- a/src/wp-admin/install.php +++ b/src/wp-admin/install.php @@ -143,7 +143,7 @@ function display_setup_form( $error = null ) {

- diff --git a/src/wp-admin/setup-config.php b/src/wp-admin/setup-config.php index dd6794d5f8e27..ec4d1a8bbbdbc 100644 --- a/src/wp-admin/setup-config.php +++ b/src/wp-admin/setup-config.php @@ -240,7 +240,7 @@ function setup_config_display_header( $body_classes = array() ) {
- diff --git a/src/wp-admin/user-new.php b/src/wp-admin/user-new.php index ba027b06bb366..3136705f60a03 100644 --- a/src/wp-admin/user-new.php +++ b/src/wp-admin/user-new.php @@ -603,7 +603,7 @@
- From d1c6be6bdae3fb1ef62321df1a8f36060ab26069 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Sun, 2 Aug 2026 22:23:00 +0000 Subject: [PATCH 292/336] Widgets: Show post excerpts in On This Day widget if no title. Match the behavior of posts in list tables by showing a short excerpt in the On This Day widget when the post does not have a saved title. Developed in https://github.com/WordPress/wordpress-develop/pull/12581 Props alshakero, softglaze, iamraju, mirmpro, shailu25, bph, nazmulasif, wildworks, annezazu, mukesh27, peterwilsoncc, joedolson. Fixes #65658. git-svn-id: https://develop.svn.wordpress.org/trunk@62968 602fd350-edb4-49c9-b593-d223f7449a82 --- .../includes/dashboard-on-this-day.php | 20 +- .../tests/admin/wpDashboardOnThisDay.php | 191 +++++++++++++++++- 2 files changed, 200 insertions(+), 11 deletions(-) diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php index 1939f8ca4b0e3..e9557a60720c8 100644 --- a/src/wp-admin/includes/dashboard-on-this-day.php +++ b/src/wp-admin/includes/dashboard-on-this-day.php @@ -114,10 +114,19 @@ function wp_dashboard_on_this_day() {
    ID ) && ! post_password_required( $year_post ) ) { + $excerpt = get_the_excerpt( $year_post ); + + if ( is_string( $excerpt ) && '' !== $excerpt ) { + $no_title_excerpt = wp_trim_words( $excerpt, 15 ); + } + } } $author_id = (int) $year_post->post_author; @@ -125,7 +134,14 @@ function wp_dashboard_on_this_day() { $show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id; ?>
  • - + + + + ' . esc_html( diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php index 471324f9648ec..a2b1cdbfaff1f 100644 --- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php +++ b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php @@ -58,23 +58,28 @@ private function set_up_dashboard_screen() { * @param string $title Post title. * @param int $years_ago Number of years before today. * @param string $time Post time. + * @param array $post_args Additional post arguments. * @return int Post ID. */ private function create_matching_post( int $author_id, string $title = 'A memory from last year', int $years_ago = 1, - string $time = '12:00:00' + string $time = '12:00:00', + array $post_args = array() ): int { $post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time; return self::factory()->post->create( - array( - 'post_author' => $author_id, - 'post_date' => $post_date, - 'post_date_gmt' => get_gmt_from_date( $post_date ), - 'post_status' => 'publish', - 'post_title' => $title, + array_merge( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ), + $post_args ) ); } @@ -338,6 +343,162 @@ public function test_widget_groups_posts_by_year() { /** * @ticket 65116 * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_includes_trimmed_excerpt_for_untitled_posts() { + wp_set_current_user( self::$user_id ); + + $words = array(); + for ( $n = 1; $n <= 20; $n++ ) { + $words[] = 'word' . $n; + } + + $this->create_matching_post( + self::$user_id, + '', + 1, + '12:00:00', + array( + 'post_excerpt' => implode( ' ', $words ), + ) + ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringContainsString( 'word15', $output, 'The 15th word should be present.' ); + $this->assertStringNotContainsString( 'word16', $output, 'The 16th word should be trimmed.' ); + $this->assertStringContainsString( '…', $output, 'The excerpt should end with an ellipsis.' ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_does_not_append_excerpt_to_titled_posts() { + wp_set_current_user( self::$user_id ); + + $this->create_matching_post( + self::$user_id, + 'A titled anniversary memory', + 1, + '12:00:00', + array( + 'post_excerpt' => 'This excerpt should not be shown.', + ) + ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'A titled anniversary memory', $output ); + $this->assertStringNotContainsString( 'This excerpt should not be shown.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() { + $this->set_up_dashboard_screen(); + + wp_set_current_user( self::$user_id ); + + $this->create_matching_post( + self::$user_id, + '', + 1, + '12:00:00', + array( + 'post_excerpt' => 'Readable private anniversary memory.', + 'post_status' => 'private', + ) + ); + + add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + + ob_start(); + try { + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + } finally { + remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + } + + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringContainsString( 'Readable private anniversary memory.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() { + $this->set_up_dashboard_screen(); + + wp_set_current_user( self::$user_id ); + + $post_id = $this->create_matching_post( + self::$other_user_id, + '', + 1, + '12:00:00', + array( + 'post_excerpt' => 'Unreadable private anniversary memory.', + 'post_status' => 'private', + ) + ); + + add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + + ob_start(); + try { + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + } finally { + remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + } + + $this->assertFalse( current_user_can( 'read_post', $post_id ) ); + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringNotContainsString( 'Unreadable private anniversary memory.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() { + $this->set_up_dashboard_screen(); + + wp_set_current_user( self::$user_id ); + + $this->create_matching_post( + self::$user_id, + '', + 1, + '12:00:00', + array( + 'post_excerpt' => 'Private anniversary memory.', + 'post_password' => 'secret', + ) + ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringNotContainsString( 'Private anniversary memory.', $output ); + } + + /** * @covers ::wp_dashboard_on_this_day * @covers ::wp_dashboard_on_this_day_get_posts */ @@ -353,8 +514,20 @@ public function test_widget_limits_posts_to_ten() { $output = ob_get_clean(); $this->assertStringContainsString( '10 posts have been published on ' . wp_date( 'F jS' ) . ':', $output ); - $this->assertStringContainsString( 'Anniversary post 1<', $output ); - $this->assertStringContainsString( 'Anniversary post 10<', $output ); + $this->assertMatchesRegularExpression( '/>\s*Anniversary post 1\s*<\/a>/', $output ); + $this->assertMatchesRegularExpression( '/>\s*Anniversary post 10\s*<\/a>/', $output ); $this->assertStringNotContainsString( 'Anniversary post 11', $output ); } + + /** + * Filters the On This Day query to include private posts. + * + * @param array $args WP_Query arguments. + * @return array Filtered query arguments. + */ + public function filter_on_this_day_query_private_posts( $args ) { + $args['post_status'] = array( 'private' ); + + return $args; + } } From a379d09c5be455a9fdea5168c9380c0d98d593d4 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sun, 2 Aug 2026 23:48:34 +0000 Subject: [PATCH 293/336] Tests: Use `assertTrue()`/`assertFalse()` instead of `assertSame()` with booleans. Using the dedicated boolean assertions clarifies intent and produces more descriptive failure messages. Follow-up to [51453]. Props Soean, mukesh27. See #64894. git-svn-id: https://develop.svn.wordpress.org/trunk@62969 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/tests/blocks/wpBlockType.php | 8 ++++---- .../interactivity-api/wpInteractivityAPI-wp-bind.php | 4 ++-- tests/phpunit/tests/rest-api/rest-post-meta-fields.php | 2 +- tests/phpunit/tests/rest-api/wpRestMenusController.php | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/blocks/wpBlockType.php b/tests/phpunit/tests/blocks/wpBlockType.php index a73efa8ce8a7d..3e69b67d965db 100644 --- a/tests/phpunit/tests/blocks/wpBlockType.php +++ b/tests/phpunit/tests/blocks/wpBlockType.php @@ -528,9 +528,9 @@ public function test_variations_callback_are_lazy_loaded() { ) ); - $this->assertSame( false, $callback_called, 'The callback should not be called before the variations are accessed.' ); + $this->assertFalse( $callback_called, 'The callback should not be called before the variations are accessed.' ); $block_type->variations; // access the variations. - $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' ); + $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' ); } /** @@ -555,7 +555,7 @@ public function test_variations_precedence_over_callback_post_registration() { // If the variations are defined after registration but before first access, the callback should not override it. $this->assertSameSets( $test_variations, $block_type->get_variations(), 'Variations are same as variations set' ); - $this->assertSame( false, $callback_called, 'The callback was never called.' ); + $this->assertFalse( $callback_called, 'The callback was never called.' ); } /** @@ -617,7 +617,7 @@ public function test_get_block_type_variations_filter_with_variation_callback() $obtained_variations = $block_type->variations; // access the variations. - $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' ); + $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' ); $this->assertSameSets( $obtained_variations, $expected_variations, 'The variations obtained from the callback should be filtered.' ); } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index e80930357b6fc..1951919941a32 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -454,7 +454,7 @@ public function test_wp_bind_handles_nested_bindings() { public function test_wp_bind_handles_true_value() { $html = '
    '; list($p) = $this->process_directives( $html ); - $this->assertSame( true, $p->get_attribute( 'id' ) ); + $this->assertTrue( $p->get_attribute( 'id' ) ); } /** @@ -467,7 +467,7 @@ public function test_wp_bind_handles_true_value() { public function test_wp_bind_ignores_unique_ids() { $html = '
    '; list($p) = $this->process_directives( $html ); - $this->assertSame( true, $p->get_attribute( 'id' ) ); + $this->assertTrue( $p->get_attribute( 'id' ) ); $html = '
    '; list($p) = $this->process_directives( $html ); diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php index 5ce72a57fa55f..0f8584f469892 100644 --- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php @@ -2348,7 +2348,7 @@ public function test_update_meta_with_unchanged_values_and_custom_authentication $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertSame( false, $data['meta']['authenticated'] ); + $this->assertFalse( $data['meta']['authenticated'] ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestMenusController.php b/tests/phpunit/tests/rest-api/wpRestMenusController.php index 864b09417d2cb..46f9877e3cfc0 100644 --- a/tests/phpunit/tests/rest-api/wpRestMenusController.php +++ b/tests/phpunit/tests/rest-api/wpRestMenusController.php @@ -316,7 +316,7 @@ public function test_update_item() { $data = $response->get_data(); $this->assertSame( 'New Name', $data['name'] ); $this->assertSame( 'New Description', $data['description'] ); - $this->assertSame( true, $data['auto_add'] ); + $this->assertTrue( $data['auto_add'] ); $this->assertSame( 'new-name', $data['slug'] ); $this->assertSame( 'just meta', $data['meta']['test_single_menu'] ); $this->assertFalse( isset( $data['meta']['test_cat_meta'] ) ); From 07220f23b7e49acf0195820471cc318e2556a639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= Date: Mon, 3 Aug 2026 10:36:23 +0000 Subject: [PATCH 294/336] View config filters: lowercase dynamic filter names. Props oandregal, ntsekouras. See #65577. git-svn-id: https://develop.svn.wordpress.org/trunk@62970 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-view-config-data.php | 12 +++--- src/wp-includes/default-filters.php | 4 +- src/wp-includes/view-config.php | 41 +++++++++++++++---- tests/phpunit/tests/view-config.php | 24 ++++++++++- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php index 8c3d255fab82d..fa04ced6de026 100644 --- a/src/wp-includes/class-wp-view-config-data.php +++ b/src/wp-includes/class-wp-view-config-data.php @@ -121,9 +121,9 @@ private function get_data() { * Applies the entity view configuration filter and returns the result. * * Exposes the container through the dynamic - * `get_entity_view_config_{$kind}_{$name}` filter so that core and third - * parties can provide the configuration for a specific entity, then - * reconciles the filtered container back into a plain configuration array, + * `get_entity_view_config_{$kind}_{$name}` filter (with the dynamic portions + * lowercased), so that core and third parties can provide the configuration for a specific entity, + * then reconciles the filtered container back into a plain configuration array, * limited to the documented configuration keys. * * @since 7.1.0 @@ -137,7 +137,9 @@ public function apply_filters( $kind, $name ) { * Filters the view configuration for a given entity. * * The dynamic portions of the hook name, `$kind` and `$name`, refer to the - * entity kind (e.g. `postType`) and the entity name (e.g. `page`). + * entity kind (e.g. `postType`) and the entity name (e.g. `page`), + * lowercased — so the `postType`/`page` entity maps to the + * `get_entity_view_config_posttype_page` hook. * * Callbacks receive a WP_View_Config_Data object and change the * configuration through its methods. Each write method takes the schema @@ -183,7 +185,7 @@ public function apply_filters( $kind, $name ) { * } */ apply_filters( - "get_entity_view_config_{$kind}_{$name}", + wp_get_entity_view_config_hook_name( $kind, $name ), $this, array( 'kind' => $kind, diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 66504d37ad84d..ea6fee0dab3ad 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -827,8 +827,8 @@ // callbacks registered at the default compose on top of them // regardless of registration order. add_filter( - "get_entity_view_config_postType_{$post_type}", - "_wp_get_entity_view_config_post_type_{$post_type}", + "get_entity_view_config_posttype_{$post_type}", + "_wp_get_entity_view_config_posttype_{$post_type}", 5 ); } diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index c4d20979b846f..b97b221ec2a32 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -4,12 +4,33 @@ * * Builds the default view configuration for an entity and exposes it through * the dynamic `get_entity_view_config_{$kind}_{$name}` filter so core and third - * parties can provide the configuration for a specific entity. + * parties can provide the configuration for a specific entity. The dynamic + * portions of the hook name are lowercased, e.g. + * `get_entity_view_config_posttype_page` for the `page` post type. * * @package WordPress * @since 7.1.0 */ +/** + * Builds the name of the dynamic filter that provides the view configuration + * for an entity. + * + * The entity kind and name are embedded in the hook name lowercased, so the + * hook follows the WordPress convention of lowercase hook names regardless of + * how the entity identifiers are spelled: the `postType`/`page` entity maps to + * the `get_entity_view_config_posttype_page` hook. + * + * @since 7.1.0 + * + * @param string $kind The entity kind (e.g. `postType`). + * @param string $name The entity name (e.g. `page`). + * @return string The filter name. + */ +function wp_get_entity_view_config_hook_name( $kind, $name ) { + return strtolower( "get_entity_view_config_{$kind}_{$name}" ); +} + /** * Builds the default `form` configuration for post types that don't provide their own. * @@ -27,7 +48,7 @@ * * @return array The default form configuration. */ -function _wp_get_default_post_type_form() { +function _wp_get_default_posttype_form() { return array( 'layout' => array( 'type' => 'panel' ), 'fields' => array( @@ -97,8 +118,10 @@ function _wp_get_default_post_type_form() { * Returns the view configuration for the given entity. * * Builds the default configuration shared by all entities and then exposes it - * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter so that core - * and third parties can provide the configuration for a specific entity. + * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter — with the + * dynamic portions lowercased, see wp_get_entity_view_config_hook_name() + * — so that core and third parties can provide the configuration for a + * specific entity. * * @since 7.1.0 * @@ -148,7 +171,7 @@ function wp_get_entity_view_config( $kind, $name ) { 'default_view' => $default_view, 'default_layouts' => $default_layouts, 'view_list' => $view_list, - 'form' => 'postType' === $kind ? _wp_get_default_post_type_form() : array(), + 'form' => 'postType' === $kind ? _wp_get_default_posttype_form() : array(), ); $data = new WP_View_Config_Data( $config ); @@ -164,7 +187,7 @@ function wp_get_entity_view_config( $kind, $name ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_page( $data ) { +function _wp_get_entity_view_config_posttype_page( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -304,7 +327,7 @@ function _wp_get_entity_view_config_post_type_page( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_block( $data ) { +function _wp_get_entity_view_config_posttype_wp_block( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -422,7 +445,7 @@ function _wp_get_entity_view_config_post_type_wp_block( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template_part( $data ) { +function _wp_get_entity_view_config_posttype_wp_template_part( $data ) { $default_layouts = array( 'table' => array( 'layout' => array( @@ -524,7 +547,7 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $data ) { * @param WP_View_Config_Data $data The view configuration container for the entity. * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template( $data ) { +function _wp_get_entity_view_config_posttype_wp_template( $data ) { $default_view = array( 'type' => 'grid', 'perPage' => 20, diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php index 75e5c3327266b..cabd15d831494 100644 --- a/tests/phpunit/tests/view-config.php +++ b/tests/phpunit/tests/view-config.php @@ -69,7 +69,7 @@ class Tests_View_Config_API extends WP_UnitTestCase { * Tears down each test. */ public function tear_down() { - remove_all_filters( 'get_entity_view_config_postType_unregistered_cpt' ); + remove_all_filters( 'get_entity_view_config_posttype_unregistered_cpt' ); remove_all_filters( 'get_entity_view_config_custom_kind_custom_name' ); parent::tear_down(); } @@ -119,6 +119,28 @@ public function test_view_list_uses_post_type_all_items_label() { unregister_post_type( 'view_config_cpt' ); } + /** + * The dynamic filter name lowercases the entity kind and name. + */ + public function test_filter_hook_name_is_lowercased() { + $called = false; + add_filter( + 'get_entity_view_config_posttype_unregistered_cpt', + function ( $data ) use ( &$called ) { + $called = true; + return $data; + } + ); + + wp_get_entity_view_config( 'postType', 'Unregistered_CPT' ); + + $this->assertTrue( $called ); + $this->assertSame( + 'get_entity_view_config_posttype_unregistered_cpt', + wp_get_entity_view_config_hook_name( 'postType', 'Unregistered_CPT' ) + ); + } + /** * The dynamic filter receives the data container and the entity descriptor. */ From 3d9a592faf76bf2b24c5676347cc89418cb21952 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Mon, 3 Aug 2026 11:35:52 +0000 Subject: [PATCH 295/336] Docs: Restore the `@return` tag for `WP_Duotone::is_preset()`. Removes a duplicate `@param` tag carrying an outdated type and restores the description of the returned value. Developed in: https://github.com/WordPress/wordpress-develop/pull/12791 Follow-up to [61603]. Props bejignesh, mukesh27, wildworks. See #64896. git-svn-id: https://develop.svn.wordpress.org/trunk@62971 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-duotone.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-duotone.php b/src/wp-includes/class-wp-duotone.php index b75b01619fbee..0e12e7ca7f306 100644 --- a/src/wp-includes/class-wp-duotone.php +++ b/src/wp-includes/class-wp-duotone.php @@ -569,8 +569,8 @@ private static function get_slug_from_attribute( $duotone_attr ) { * * @since 6.3.0 * - * @param string $duotone_attr The duotone attribute from a block. * @param string|string[] $duotone_attr The duotone attribute from a block. + * @return bool True if the duotone preset present and valid. */ private static function is_preset( $duotone_attr ) { if ( ! is_string( $duotone_attr ) ) { From be1c69c4c97a80a24620b960ff8719a01097751e Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Mon, 3 Aug 2026 12:13:24 +0000 Subject: [PATCH 296/336] Administration: Fix install button icon alignment in theme preview. When installing a theme from the Details & Preview overlay, the animated icon shown in the Install button was taller than the button itself, so the button grew and its label shifted while the install was in progress. Matching the icon height to the button height keeps the button at a stable size and the icon aligned with the label throughout the updating and updated states. Developed in: https://github.com/WordPress/wordpress-develop/pull/12799 Follow-up to [62516]. Props eishanoor, kosvrouvas, mosescursor, r1k0, shailu25, ugyensupport, vedantere, wildworks. Fixes #65601. git-svn-id: https://develop.svn.wordpress.org/trunk@62972 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/themes.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-admin/css/themes.css b/src/wp-admin/css/themes.css index be495568c89b7..24be8ac58c5be 100644 --- a/src/wp-admin/css/themes.css +++ b/src/wp-admin/css/themes.css @@ -1976,6 +1976,11 @@ body.full-overlay-active { line-height: 2.30769231; /* 30px for 32px height with 13px font */ } +.theme-install-overlay .wp-full-overlay-header .button.updating-message:before, +.theme-install-overlay .wp-full-overlay-header .button.updated-message:before { + line-height: 1.5; /* 30px (20px * 1.5) - matches the button above */ +} + .theme-install-overlay .wp-full-overlay-sidebar { background: #f0f0f1; border-right: 1px solid #dcdcde; From 5bc2c6228c78ca17552a906c2bb4e47a97e87bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= Date: Mon, 3 Aug 2026 14:20:36 +0000 Subject: [PATCH 297/336] View config REST Endpoint: remove `search` and `page`. The `search` and `page` parameters source of truth is the URL, and cannot be configured via the filters. Props oandregal, ntsekouras, jorgefilipecosta. Fixes #65577. git-svn-id: https://develop.svn.wordpress.org/trunk@62973 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-view-config-data.php | 4 +-- .../class-wp-rest-view-config-controller.php | 9 +++---- .../rest-api/rest-view-config-controller.php | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php index fa04ced6de026..be85e9dc10d60 100644 --- a/src/wp-includes/class-wp-view-config-data.php +++ b/src/wp-includes/class-wp-view-config-data.php @@ -355,13 +355,13 @@ public function replace( array $patch, int $version ) { * * ```php * array( - * 'default_view' => array( 'search' => 'new search', 'fields' => array( 'newField' ) ), + * 'default_view' => array( 'titleField' => 'newTitleField', 'fields' => array( 'newField' ) ), * 'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ), * 'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ), * ) * ``` * - * - default_view will be updated so the search string is 'new search' and the newField is appended to the list of fields. + * - default_view will be updated so the titleField is 'newTitleField' and the newField is appended to the list of fields. * - default_layouts will be updated so that newField is appended to the badgeFields. * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index 6b23f35cbaf47..64c1ebe1ba921 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -391,15 +391,15 @@ public function get_item_schema() { /** * Returns the schema properties shared by all view types (ViewBase), excluding 'type'. * + * Note that `search` and `page` are not part of the schema: they are managed + * via the URL, which is their only source of truth. + * * @since 7.1.0 * * @return array Schema properties for the base view configuration. */ protected function get_view_base_schema() { return array( - 'search' => array( - 'type' => 'string', - ), 'filters' => array( 'type' => 'array', 'items' => array( @@ -444,9 +444,6 @@ protected function get_view_base_schema() { ), ), ), - 'page' => array( - 'type' => 'integer', - ), 'perPage' => array( 'type' => 'integer', ), diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index 9bfbdd67c7021..34fcd55e2466d 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -385,4 +385,29 @@ public function test_get_item_schema() { array_keys( $schema['properties'] ) ); } + + /** + * `search` and `page` are not part of the view schema: they are managed via + * the URL, which is their only source of truth. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema_excludes_url_managed_view_properties() { + $controller = new WP_REST_View_Config_Controller(); + $schema = $controller->get_item_schema(); + + $views = array( + 'default_view' => $schema['properties']['default_view']['properties'], + 'view_list item view' => $schema['properties']['view_list']['items']['properties']['view']['properties'], + 'default_layouts.table' => $schema['properties']['default_layouts']['properties']['table']['properties'], + 'default_layouts.grid' => $schema['properties']['default_layouts']['properties']['grid']['properties'], + 'default_layouts.list' => $schema['properties']['default_layouts']['properties']['list']['properties'], + 'default_layouts.activity' => $schema['properties']['default_layouts']['properties']['activity']['properties'], + ); + + foreach ( $views as $label => $properties ) { + $this->assertArrayNotHasKey( 'search', $properties, "$label should not declare a `search` property." ); + $this->assertArrayNotHasKey( 'page', $properties, "$label should not declare a `page` property." ); + } + } } From dcf58dc786d736306609c4804464db501f74359e Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Mon, 3 Aug 2026 16:56:56 +0000 Subject: [PATCH 298/336] Build/Test Tools: Make runner override variable more general. [62891] introduced the ability to override the runner used for a GitHub Actions job using a repository or organization variable. While initially named `PHPUNIT_RUNNER`, overriding the runner for a specific job could be useful in more situations. This renames the variable chacked to `RUNNER_GROUP`. Fixes #65749. git-svn-id: https://develop.svn.wordpress.org/trunk@62974 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 4ce4e65b0ba12..abe472e03b6d1 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -129,7 +129,7 @@ jobs: # - Submit the test results to the WordPress.org host test results. phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} - runs-on: ${{ vars.PHPUNIT_RUNNER || inputs.os }} + runs-on: ${{ vars.RUNNER_GROUP || inputs.os }} timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read From 878af1bb999fabd044264fa1a63072b3b6cff851 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Mon, 3 Aug 2026 18:58:00 +0000 Subject: [PATCH 299/336] External Libraries: Upgrade PHPMailer to version 7.1.1. This is a maintenance and minor security release. References: * [https://github.com/PHPMailer/PHPMailer/releases/tag/v7.1.1 PHPMailer 7.1.1 release notes] * [https://github.com/PHPMailer/PHPMailer/releases/tag/v7.1.0 PHPMailer 7.1.0 release notes] * [https://github.com/PHPMailer/PHPMailer/compare/v7.0.2...v7.1.1 Full list of changes in PHPMailer 7.1.1] Follow-up to [54937], [55557], [56484], [57137], [59246], [59481], [60623], [60813], [60888], [61249], [61468]. Props hareesh-pillai, Synchro, jrf. Fixes #65790. git-svn-id: https://develop.svn.wordpress.org/trunk@62975 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/PHPMailer/PHPMailer.php | 99 ++++++++++++++++++++----- src/wp-includes/PHPMailer/POP3.php | 25 +++++-- src/wp-includes/PHPMailer/SMTP.php | 4 +- 3 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/wp-includes/PHPMailer/PHPMailer.php b/src/wp-includes/PHPMailer/PHPMailer.php index 2bb3578c7e0d9..4900cbc43afef 100644 --- a/src/wp-includes/PHPMailer/PHPMailer.php +++ b/src/wp-includes/PHPMailer/PHPMailer.php @@ -59,6 +59,7 @@ class PHPMailer const ICAL_METHOD_REFRESH = 'REFRESH'; const ICAL_METHOD_COUNTER = 'COUNTER'; const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER'; + const RFC822_DATE_FORMAT = 'D, j M Y H:i:s O'; /** * Email priority. @@ -77,7 +78,7 @@ class PHPMailer public $CharSet = self::CHARSET_ISO88591; /** - * The MIME Content-type of the message. + * The MIME Content-Type of the message. * * @var string */ @@ -159,7 +160,7 @@ class PHPMailer public $Ical = ''; /** - * Value-array of "method" in Contenttype header "text/calendar" + * Value-array of "method" in Content-Type header "text/calendar" * * @var string[] */ @@ -768,7 +769,7 @@ class PHPMailer * * @var string */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * Error severity: message only, continue processing. @@ -1283,26 +1284,27 @@ protected function addAnAddress($kind, $address, $name = '') /** * Parse and validate a string containing one or more RFC822-style comma-separated email addresses * of the form "display name
    " into an array of name/address pairs. - * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available. + * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available and + * the deprecated $useimap argument is truthy. * Note that quotes in the name part are removed. * * @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation * * @param string $addrstr The address list string - * @param null $useimap Unused. Argument has been deprecated in PHPMailer 6.11.0. - * Previously this argument determined whether to use - * the IMAP extension to parse the list and accepted a boolean value. + * @param bool|null $useimap Deprecated in PHPMailer 6.11.0. + * Truthy values request the deprecated IMAP parser + * and trigger a deprecation warning. * @param string $charset The charset to use when decoding the address list string. * * @return array */ public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591) { - if ($useimap !== null) { + if ($useimap == true) { trigger_error(self::lang('deprecated_argument') . '$useimap', E_USER_DEPRECATED); } $addresses = []; - if (function_exists('imap_rfc822_parse_adrlist')) { + if ($useimap == true && function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_rfc822_parse_adrlistRemoved -- wrapped in function_exists() $list = imap_rfc822_parse_adrlist($addrstr, ''); @@ -1779,6 +1781,8 @@ public function preSend() //Trim subject consistently $this->Subject = trim($this->Subject); + + //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); @@ -1853,7 +1857,7 @@ public function postSend() return $this->mailSend($this->MIMEHeader, $this->MIMEBody); default: $sendMethod = $this->Mailer . 'Send'; - if (method_exists($this, $sendMethod)) { + if (!empty($this->Mailer) && method_exists($this, $sendMethod)) { return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody); } @@ -1911,7 +1915,7 @@ protected function sendmailSend($header, $body) // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. // Also don't add the -f automatically unless it has been set either via Sender - // or sendmail_path. Otherwise it can introduce new problems. + // or sendmail_path. Otherwise, it can introduce new problems. // @see http://github.com/PHPMailer/PHPMailer/issues/2298 if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { $sendmailArgs[] = '-f' . $this->Sender; @@ -2510,7 +2514,7 @@ public static function setLanguage($langcode = 'en', $lang_path = '') 'authenticate' => 'SMTP Error: Could not authenticate.', 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . - ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', + ' your php.ini, switch to macOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', @@ -2847,7 +2851,10 @@ public function createHeader() { $result = ''; - $result .= $this->headerLine('Date', '' === $this->MessageDate ? self::rfcDate() : $this->MessageDate); + $result .= $this->headerLine( + 'Date', + self::sanitiseDate($this->MessageDate) + ); //The To header is created automatically by mail(), so needs to be omitted here if ('mail' !== $this->Mailer) { @@ -2916,7 +2923,7 @@ public function createHeader() ); } elseif (is_string($this->XMailer) && trim($this->XMailer) !== '') { //Some string - $result .= $this->headerLine('X-Mailer', trim($this->XMailer)); + $result .= $this->headerLine('X-Mailer', $this->secureHeader(trim($this->XMailer))); } //Other values result in no X-Mailer header if ('' !== $this->ConfirmReadingTo) { @@ -2966,13 +2973,20 @@ public function getMailMIME() break; default: //Catches case 'plain': and case '': - $result .= $this->textLine('Content-Type: ' . $this->ContentType . '; charset=' . $this->CharSet); + $result .= $this->textLine( + 'Content-Type: ' . + $this->secureHeader($this->ContentType) . + '; charset=' . $this->secureHeader($this->CharSet) + ); $ismultipart = false; break; } + if (!$this->validateEncoding($this->Encoding)) { + throw new Exception(self::lang('encoding') . $this->Encoding); + } //RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT !== $this->Encoding) { - //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE + //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit, or binary CTE if ($ismultipart) { if (static::ENCODING_8BIT === $this->Encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT); @@ -3047,6 +3061,9 @@ public function createBody() $this->setWordWrap(); + if (!$this->validateEncoding($this->Encoding)) { + throw new Exception(self::lang('encoding') . $this->Encoding); + } $bodyEncoding = $this->Encoding; $bodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? @@ -4166,7 +4183,7 @@ public function addStringEmbeddedImage( protected function validateEncoding($encoding) { return in_array( - $encoding, + strtolower($encoding), [ self::ENCODING_7BIT, self::ENCODING_QUOTED_PRINTABLE, @@ -4426,7 +4443,7 @@ protected function setError($msg) } /** - * Return an RFC 822 formatted date. + * Return the current date and time as an RFC 822 formatted date. * * @return string */ @@ -4436,7 +4453,51 @@ public static function rfcDate() //Will default to UTC if it's not set properly in php.ini date_default_timezone_set(@date_default_timezone_get()); - return date('D, j M Y H:i:s O'); + return date(self::RFC822_DATE_FORMAT); + } + + /** + * Normalise a user-supplied date into a correctly-formatted RFC 5322 date value + * string suitable for use in the Date header. + * + * Accepts: + * - A {@see \DateTime} (or \DateTimeImmutable) object + * - Any date/time string understood by PHP's DateTime constructor (RFC 5322, ISO 8601, + * Unix timestamp with leading "@", natural-language strings, etc.) + * + * Dates in the future are not permitted for email headers; if the parsed date is later + * than "now" the method falls back to the current time via {@see self::rfcDate()}. + * An empty value, a non-string/non-DateTime argument, or any value that cannot be + * parsed will likewise fall back to {@see self::rfcDate()}. + * + * @param \DateTime|\DateTimeImmutable|string $date The date to normalise + * + * @return string An RFC 5322-formatted date string + */ + private static function sanitiseDate($date) + { + try { + //Ensure the default timezone is set properly + date_default_timezone_set(@date_default_timezone_get()); + + if ($date instanceof \DateTimeInterface) { + $dt = $date; + } elseif (is_string($date) && $date !== '') { + $dt = new \DateTime($date); + } else { + //Empty string, null, or any unsupported type + return self::rfcDate(); + } + + //Reject future dates — they are invalid for outgoing message headers + if ($dt->getTimestamp() > time()) { + return self::rfcDate(); + } + + return $dt->format(self::RFC822_DATE_FORMAT); + } catch (\Exception $e) { + return self::rfcDate(); + } } /** diff --git a/src/wp-includes/PHPMailer/POP3.php b/src/wp-includes/PHPMailer/POP3.php index 186fe9fe47ab7..0ba9678373217 100644 --- a/src/wp-includes/PHPMailer/POP3.php +++ b/src/wp-includes/PHPMailer/POP3.php @@ -47,7 +47,7 @@ class POP3 * @var string * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead. */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * Default POP3 port number. @@ -212,9 +212,9 @@ public function authorise($host, $port = false, $timeout = false, $username = '' } else { $this->tval = (int) $timeout; } - $this->do_debug = $debug_level; - $this->username = $username; - $this->password = $password; + $this->do_debug = (int) $debug_level; + $this->username = self::stripControls($username); + $this->password = self::stripControls($password); //Reset the error log $this->errors = []; //Connect @@ -319,7 +319,8 @@ public function login($username = '', $password = '') if (empty($password)) { $password = $this->password; } - + $username = self::stripControls($username); + $password = self::stripControls($password); //Send the Username $this->sendString("USER $username" . static::LE); $pop3_response = $this->getResponse(); @@ -407,7 +408,7 @@ protected function sendString($string) /** * Checks the POP3 server response. - * Looks for for +OK or -ERR. + * Looks for +OK or -ERR. * * @param string $string * @@ -467,4 +468,16 @@ protected function catchWarning($errno, $errstr, $errfile, $errline) "errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline" ); } + + /** + * Strip all control chars from a string. + * + * @param $string + * + * @return string + */ + protected static function stripControls($string) + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $string); + } } diff --git a/src/wp-includes/PHPMailer/SMTP.php b/src/wp-includes/PHPMailer/SMTP.php index 559b52c45e8f8..f0957b80a919f 100644 --- a/src/wp-includes/PHPMailer/SMTP.php +++ b/src/wp-includes/PHPMailer/SMTP.php @@ -36,7 +36,7 @@ class SMTP * @var string * @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead. */ - const VERSION = '7.0.2'; + const VERSION = '7.1.1'; /** * SMTP line break constant. @@ -1289,7 +1289,7 @@ public function getServerExtList() * 3. EHLO has been sent - * $name == 'HELO'|'EHLO': returns the server name * $name == any other string: if extension $name exists, returns True - * or its options (e.g. AUTH mechanisms supported). Otherwise returns False. + * or its options (e.g. AUTH mechanisms supported). Otherwise, returns False. * * @param string $name Name of SMTP extension or 'HELO'|'EHLO' * From aea966d3d51a6d8495a08c6d0c22529ed6fa04f8 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Mon, 3 Aug 2026 19:42:08 +0000 Subject: [PATCH 300/336] Media: Equalize padding for filter bar between list and grid views. The list and grid filter panels had different padding following [61757]. This is an undesirable difference, and should be equalized. Apply scoped padding to match the two filter bars. Developed in https://github.com/WordPress/wordpress-develop/pull/12664 Props afercia, softglaze, khokansardar, joedolson. Fixes #65697. git-svn-id: https://develop.svn.wordpress.org/trunk@62976 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/media.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css index 5a033b98ba350..3d7b0c9455c83 100644 --- a/src/wp-admin/css/media.css +++ b/src/wp-admin/css/media.css @@ -451,6 +451,16 @@ border color while dragging a file over the uploader drop area */ margin: 0 6px 0 0; } +/* Match the spacing the grid view toolbar gets from + `.attachments-browser .media-toolbar` in media-views.css, so the Media + Library filter bar is consistent in both modes. The grid view toolbar is + excluded so that rule stays the single source of its own padding: media.css + is printed after media-views.css here, so an unscoped rule would override + it. */ +.upload-php .wp-filter:not(.media-toolbar) { + padding: 12px 16px; +} + /** * Media Library grid view */ From 6693ea1fec9db0f9324262db344d6267abb6e22b Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Mon, 3 Aug 2026 19:59:48 +0000 Subject: [PATCH 301/336] Widgets: Always render the On This Day widget. While the intention was to only render the On This Day widget when it returned results, this proved to create a variety of implementation complications and some significant points of confusion for users. Remove the conditional rendering of the On This Day widget. When active without posts, display a message inviting the user to publish a new post. Developed in https://github.com/WordPress/wordpress-develop/pull/12575 Props iamchitti, mirmpro, shailu25, ugyensupport, iamraju, nazmulasif, wildworks, joedolson, mukesh27, annezazu, paaljoachim, joen. Fixes #65647. git-svn-id: https://develop.svn.wordpress.org/trunk@62977 602fd350-edb4-49c9-b593-d223f7449a82 --- .../includes/dashboard-on-this-day.php | 59 +++------ src/wp-admin/includes/dashboard.php | 4 +- .../tests/admin/wpDashboardOnThisDay.php | 116 +++++------------- 3 files changed, 46 insertions(+), 133 deletions(-) diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php index e9557a60720c8..948e128f72c59 100644 --- a/src/wp-admin/includes/dashboard-on-this-day.php +++ b/src/wp-admin/includes/dashboard-on-this-day.php @@ -7,48 +7,6 @@ * @since 7.1.0 */ -/** - * Registers the On This Day dashboard widget. - * - * Designed to be the single entry point called from the dashboard setup - * routine. The widget is always registered so that it remains available in - * Screen Options and keeps its user-customized position. When there are no - * matching posts, a marker class is added to the postbox so the widget can be - * hidden with CSS. - * - * @since 7.1.0 - */ -function wp_dashboard_on_this_day_setup() { - add_filter( 'postbox_classes_dashboard_wp_dashboard_on_this_day', 'wp_dashboard_on_this_day_postbox_classes' ); - - wp_add_dashboard_widget( - 'wp_dashboard_on_this_day', - __( 'On This Day' ), - 'wp_dashboard_on_this_day' - ); -} - -/** - * Hides the On This Day postbox when there are no posts to show. - * - * Adds the core `hidden` class so the widget stays registered — preserving its - * Screen Options entry and user-customized position — while being hidden when - * empty. A user can still reveal it via Screen Options, in which case the - * placeholder message is shown. - * - * @since 7.1.0 - * - * @param string[] $classes An array of postbox classes. - * @return string[] Filtered postbox classes. - */ -function wp_dashboard_on_this_day_postbox_classes( $classes ) { - if ( empty( wp_dashboard_on_this_day_get_posts() ) ) { - $classes[] = 'hidden'; - } - - return $classes; -} - /** * Renders the On This Day dashboard widget. * @@ -60,9 +18,20 @@ function wp_dashboard_on_this_day() { $posts = wp_dashboard_on_this_day_get_posts(); if ( empty( $posts ) ) { - // Placeholder shown when a user reveals the hidden widget via Screen - // Options on a day with no matching posts. - echo '

    ' . esc_html__( 'No posts were published on this day in previous years.' ) . '

    '; + // Placeholder shown on a day with no matching posts in previous years. + echo '

    '; + + if ( current_user_can( 'edit_posts' ) ) { + printf( + /* translators: %s: URL to the new post screen. */ + __( 'No posts were published on this day in previous years. Write one today, and be reminded about it next year.' ), + esc_url( admin_url( 'post-new.php' ) ) + ); + } else { + echo esc_html__( 'No posts were published on this day in previous years.' ); + } + + echo '

    '; return; } diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 5fdbaf7a4fa40..a0c2a23189644 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -89,11 +89,11 @@ function wp_dashboard_setup() { } // On This Day. - if ( ! function_exists( 'wp_dashboard_on_this_day_setup' ) ) { + if ( ! function_exists( 'wp_dashboard_on_this_day' ) ) { require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php'; } - wp_dashboard_on_this_day_setup(); + wp_add_dashboard_widget( 'wp_dashboard_on_this_day', __( 'On This Day' ), 'wp_dashboard_on_this_day' ); // WordPress Events and News. wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' ); diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php index a2b1cdbfaff1f..728b9bcef64d0 100644 --- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php +++ b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php @@ -10,6 +10,8 @@ class Tests_Admin_wpDashboardOnThisDay extends WP_UnitTestCase { protected static int $other_user_id; + protected static int $subscriber_id; + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php'; @@ -25,30 +27,24 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { 'role' => 'author', ) ); + self::$subscriber_id = $factory->user->create( + array( + 'display_name' => 'Reader', + 'role' => 'subscriber', + ) + ); } public static function wpTearDownAfterClass() { self::delete_user( self::$user_id ); self::delete_user( self::$other_user_id ); + self::delete_user( self::$subscriber_id ); } - public function tear_down() { - unset( $GLOBALS['wp_meta_boxes']['dashboard'] ); - - parent::tear_down(); - } - - /** - * Sets up the globals needed to register dashboard widgets. - */ - private function set_up_dashboard_screen() { - if ( ! function_exists( 'wp_add_dashboard_widget' ) ) { - require_once ABSPATH . 'wp-admin/includes/dashboard.php'; - } + public function set_up() { + parent::set_up(); set_current_screen( 'dashboard' ); - - $GLOBALS['wp_meta_boxes']['dashboard'] = array(); } /** @@ -119,71 +115,6 @@ private static function get_date_query_clause( string $date ): array { return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) ); } - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day_setup - */ - public function test_setup_always_registers_widget_and_postbox_class_filter() { - $this->set_up_dashboard_screen(); - - wp_set_current_user( self::$user_id ); - - wp_dashboard_on_this_day_setup(); - - $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); - - $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); - $this->assertSame( 'On This Day', $dashboard_widgets['wp_dashboard_on_this_day']['title'] ); - $this->assertNotFalse( - has_filter( - 'postbox_classes_dashboard_wp_dashboard_on_this_day', - 'wp_dashboard_on_this_day_postbox_classes' - ) - ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day_postbox_classes - */ - public function test_postbox_classes_hides_widget_without_matching_posts() { - wp_set_current_user( self::$user_id ); - - $this->assertContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day_postbox_classes - */ - public function test_postbox_classes_does_not_hide_widget_with_matching_posts() { - wp_set_current_user( self::$user_id ); - $this->create_matching_post( self::$user_id ); - - $this->assertNotContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day_setup - */ - public function test_setup_adds_dashboard_widget_with_matching_post_from_another_author() { - $this->set_up_dashboard_screen(); - - wp_set_current_user( self::$user_id ); - $this->create_matching_post( self::$other_user_id ); - - wp_dashboard_on_this_day_setup(); - - $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); - - $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); - } - /** * @ticket 65116 * @@ -255,9 +186,28 @@ public function test_widget_outputs_placeholder_without_matching_posts() { $output = ob_get_clean(); $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); + $this->assertStringContainsString( 'Write one today', $output ); + $this->assertStringContainsString( admin_url( 'post-new.php' ), $output ); $this->assertStringNotContainsString( '
      ', $output ); } + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_widget_placeholder_omits_link_without_edit_posts_capability() { + wp_set_current_user( self::$subscriber_id ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); + $this->assertStringNotContainsString( 'Write one today', $output ); + $this->assertStringNotContainsString( admin_url( 'post-new.php' ), $output ); + } + /** * @ticket 65116 * @@ -405,8 +355,6 @@ public function test_widget_does_not_append_excerpt_to_titled_posts() { * @covers ::wp_dashboard_on_this_day */ public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() { - $this->set_up_dashboard_screen(); - wp_set_current_user( self::$user_id ); $this->create_matching_post( @@ -440,8 +388,6 @@ public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_ * @covers ::wp_dashboard_on_this_day */ public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() { - $this->set_up_dashboard_screen(); - wp_set_current_user( self::$user_id ); $post_id = $this->create_matching_post( @@ -476,8 +422,6 @@ public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() { * @covers ::wp_dashboard_on_this_day */ public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() { - $this->set_up_dashboard_screen(); - wp_set_current_user( self::$user_id ); $this->create_matching_post( From 5c45958340bf33b11ebf60f05a98e933bca7534e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 3 Aug 2026 20:38:03 +0000 Subject: [PATCH 302/336] Media: Normalize non-numeric attachment `filesize` metadata. The stored `filesize` attachment metadata was read without validation in `wp_prepare_attachment_for_js()` and `attachment_submitbox_metadata()`. Attachment metadata is untyped, and the `filesize` key is commonly written by offloading plugins from a remote storage API response, so it can arrive as a numeric string, or be empty, non-numeric, or negative when the remote lookup fails. A numeric string was passed through verbatim, making `filesizeInBytes` a string in the media modal while the `wp_filesize()` branch of the very same conditional yielded an `int`; a non-numeric value such as `'unknown'` was truthy and suppressed the fallback entirely, so `size_format()` returned `false` and the file size rendered empty even when the real file was readable. Both call sites now only trust the stored value when it is numeric and casts to an integer greater than zero, and otherwise recompute the size with `wp_filesize()`. The fallback condition also replaces `file_exists()` with `is_readable()` guarded on a non-empty string, since `get_attached_file()` can be filtered to return a non-string. PHPUnit coverage is added for both functions. Developed in https://github.com/WordPress/wordpress-develop/pull/12632. Follow-up to r34258, r52837, r62813, r62815. Props mukesh27, westonruter. See #65670. Fixes #65686. git-svn-id: https://develop.svn.wordpress.org/trunk@62978 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/media.php | 6 +- src/wp-includes/media.php | 6 +- tests/phpunit/tests/admin/includesMedia.php | 177 ++++++++++++++++++++ tests/phpunit/tests/media.php | 162 ++++++++++++++++++ 4 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 tests/phpunit/tests/admin/includesMedia.php diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php index c2d15d758ef2e..6c50a1daba4fd 100644 --- a/src/wp-admin/includes/media.php +++ b/src/wp-admin/includes/media.php @@ -3425,9 +3425,9 @@ function attachment_submitbox_metadata() { $file_size = false; - if ( isset( $meta['filesize'] ) ) { - $file_size = $meta['filesize']; - } elseif ( file_exists( $file ) ) { + if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) { + $file_size = (int) $meta['filesize']; + } elseif ( is_string( $file ) && '' !== $file && is_readable( $file ) ) { $file_size = wp_filesize( $file ); } diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 7d0a0b5d0e737..9f98538d2757f 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4716,9 +4716,9 @@ function wp_prepare_attachment_for_js( $attachment ) { $attached_file = get_attached_file( $attachment->ID ); - if ( isset( $meta['filesize'] ) ) { - $bytes = $meta['filesize']; - } elseif ( file_exists( $attached_file ) ) { + if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) { + $bytes = (int) $meta['filesize']; + } elseif ( is_string( $attached_file ) && '' !== $attached_file && is_readable( $attached_file ) ) { $bytes = wp_filesize( $attached_file ); } else { $bytes = ''; diff --git a/tests/phpunit/tests/admin/includesMedia.php b/tests/phpunit/tests/admin/includesMedia.php new file mode 100644 index 0000000000000..35c542009db8d --- /dev/null +++ b/tests/phpunit/tests/admin/includesMedia.php @@ -0,0 +1,177 @@ +|null $expected The expected file size in bytes, or null if none should be displayed. + */ + public function test_attachment_submitbox_metadata_filesize( $filesize, ?int $expected ) { + $id = self::factory()->attachment->create_object( + array( + 'file' => 'test-image.jpg', + 'post_title' => 'Attachment Title', + 'post_parent' => 0, + 'post_mime_type' => 'image/jpeg', + ) + ); + $this->assertIsInt( $id ); + + wp_update_attachment_metadata( + $id, + array( + 'width' => 50, + 'height' => 50, + 'file' => 'test-image.jpg', + 'filesize' => $filesize, + ) + ); + + $GLOBALS['post'] = get_post( $id ); + + $output = get_echo( 'attachment_submitbox_metadata' ); + + if ( null === $expected ) { + $this->assertStringNotContainsString( 'misc-pub-filesize', $output, 'The file size should not have been displayed.' ); + } else { + $this->assertStringContainsString( size_format( $expected ), $output, 'The displayed file size did not match the normalized file size.' ); + } + } + + /** + * Data provider. + * + * @return array|null }> + */ + public function data_attachment_submitbox_metadata_filesize(): array { + return array( + 'an integer' => array( + 'filesize' => 12345, + 'expected' => 12345, + ), + 'a numeric string' => array( + 'filesize' => '12345', + 'expected' => 12345, + ), + 'a float' => array( + 'filesize' => 12345.6, + 'expected' => 12345, + ), + 'a float as a string' => array( + 'filesize' => '12345.6', + 'expected' => 12345, + ), + 'an exponential string' => array( + 'filesize' => '1e3', + 'expected' => 1000, + ), + 'a value smaller than a byte' => array( + 'filesize' => 0.5, + 'expected' => null, + ), + 'zero' => array( + 'filesize' => 0, + 'expected' => null, + ), + 'a negative integer' => array( + 'filesize' => -12345, + 'expected' => null, + ), + 'an empty string' => array( + 'filesize' => '', + 'expected' => null, + ), + 'a non-numeric string' => array( + 'filesize' => 'not-a-number', + 'expected' => null, + ), + 'an array' => array( + 'filesize' => array( 12345 ), + 'expected' => null, + ), + 'null' => array( + 'filesize' => null, + 'expected' => null, + ), + 'false' => array( + 'filesize' => false, + 'expected' => null, + ), + 'true' => array( + 'filesize' => true, + 'expected' => null, + ), + ); + } + + /** + * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file. + * + * @ticket 65686 + * + * @covers ::attachment_submitbox_metadata + * + * @dataProvider data_attachment_submitbox_metadata_filesize_falls_back_to_the_file + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + */ + public function test_attachment_submitbox_metadata_filesize_falls_back_to_the_file( $filesize ) { + $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $id ); + $file = get_attached_file( $id ); + $this->assertIsString( $file ); + + $meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $meta ); + $meta['filesize'] = $filesize; + wp_update_attachment_metadata( $id, $meta ); + + $GLOBALS['post'] = get_post( $id ); + + $output = get_echo( 'attachment_submitbox_metadata' ); + + $filesize = wp_filesize( $file ); + $this->assertIsInt( $filesize ); + $this->assertStringContainsString( size_format( $filesize ), $output ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_attachment_submitbox_metadata_filesize_falls_back_to_the_file(): array { + return array( + 'a value smaller than a byte' => array( 'filesize' => 0.5 ), + 'zero' => array( 'filesize' => 0 ), + 'a negative integer' => array( 'filesize' => -12345 ), + 'an empty string' => array( 'filesize' => '' ), + 'a non-numeric string' => array( 'filesize' => 'not-a-number' ), + 'an array' => array( 'filesize' => array( 12345 ) ), + 'null' => array( 'filesize' => null ), + 'false' => array( 'filesize' => false ), + 'true' => array( 'filesize' => true ), + ); + } +} diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 03fe3b4c02460..a492cf6da189f 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -667,6 +667,168 @@ public function test_wp_prepare_attachment_for_js_without_image_sizes() { $this->assertArrayHasKey( 'sizes', $prepped ); } + /** + * Tests that a `filesize` stored in the attachment metadata is normalized to a positive integer. + * + * When the stored value cannot be normalized, it should be treated as missing so that the + * filesystem fallback runs instead. + * + * @ticket 65686 + * + * @dataProvider data_wp_prepare_attachment_for_js_filesize + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + * @param int<0, max>|null $expected The expected `filesizeInBytes` value, or null if it should not be set. + */ + public function test_wp_prepare_attachment_for_js_filesize( $filesize, ?int $expected ) { + $id = self::factory()->attachment->create_object( + array( + 'file' => 'test-image.jpg', + 'post_title' => 'Attachment Title', + 'post_parent' => 0, + 'post_mime_type' => 'image/jpeg', + ) + ); + $this->assertIsInt( $id ); + + wp_update_attachment_metadata( + $id, + array( + 'width' => 50, + 'height' => 50, + 'file' => 'test-image.jpg', + 'filesize' => $filesize, + ) + ); + + $post = get_post( $id ); + $this->assertInstanceOf( WP_Post::class, $post ); + $prepped = wp_prepare_attachment_for_js( $post ); + $this->assertIsArray( $prepped ); + + if ( null === $expected ) { + $this->assertArrayNotHasKey( 'filesizeInBytes', $prepped, 'The filesize should not have been set.' ); + $this->assertArrayNotHasKey( 'filesizeHumanReadable', $prepped, 'The human readable filesize should not have been set.' ); + } else { + $this->assertSame( $expected, $prepped['filesizeInBytes'], 'The filesize was not normalized to an integer.' ); + $this->assertSame( size_format( $expected ), $prepped['filesizeHumanReadable'], 'The human readable filesize did not match the normalized filesize.' ); + } + } + + /** + * Data provider. + * + * @return array|null }> + */ + public function data_wp_prepare_attachment_for_js_filesize(): array { + return array( + 'an integer' => array( + 'filesize' => 12345, + 'expected' => 12345, + ), + 'a numeric string' => array( + 'filesize' => '12345', + 'expected' => 12345, + ), + 'a float' => array( + 'filesize' => 12345.6, + 'expected' => 12345, + ), + 'a float as a string' => array( + 'filesize' => '12345.6', + 'expected' => 12345, + ), + 'an exponential string' => array( + 'filesize' => '1e3', + 'expected' => 1000, + ), + 'a value smaller than a byte' => array( + 'filesize' => 0.5, + 'expected' => null, + ), + 'zero' => array( + 'filesize' => 0, + 'expected' => null, + ), + 'a negative integer' => array( + 'filesize' => -12345, + 'expected' => null, + ), + 'an empty string' => array( + 'filesize' => '', + 'expected' => null, + ), + 'a non-numeric string' => array( + 'filesize' => 'not-a-number', + 'expected' => null, + ), + 'an array' => array( + 'filesize' => array( 12345 ), + 'expected' => null, + ), + 'null' => array( + 'filesize' => null, + 'expected' => null, + ), + 'false' => array( + 'filesize' => false, + 'expected' => null, + ), + 'true' => array( + 'filesize' => true, + 'expected' => null, + ), + ); + } + + /** + * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file. + * + * @ticket 65686 + * + * @dataProvider data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file + * + * @param mixed $filesize The `filesize` value stored in the attachment metadata. + */ + public function test_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file( $filesize ) { + $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $id ); + $post = get_post( $id ); + $this->assertInstanceOf( WP_Post::class, $post ); + $file = get_attached_file( $id ); + $this->assertIsString( $file ); + + $meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $meta ); + $meta['filesize'] = $filesize; + wp_update_attachment_metadata( $id, $meta ); + + $prepped = wp_prepare_attachment_for_js( $post ); + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'filesizeInBytes', $prepped ); + + $this->assertSame( wp_filesize( $file ), $prepped['filesizeInBytes'] ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file(): array { + return array( + 'a value smaller than a byte' => array( 'filesize' => 0.5 ), + 'zero' => array( 'filesize' => 0 ), + 'a negative integer' => array( 'filesize' => -12345 ), + 'an empty string' => array( 'filesize' => '' ), + 'a non-numeric string' => array( 'filesize' => 'not-a-number' ), + 'an array' => array( 'filesize' => array( 12345 ) ), + 'null' => array( 'filesize' => null ), + 'false' => array( 'filesize' => false ), + 'true' => array( 'filesize' => true ), + ); + } + /** * @ticket 19067 * @expectedDeprecated wp_convert_bytes_to_hr From 4af02ef896b2b7b75177a0406fd7b26379780506 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 4 Aug 2026 01:51:49 +0000 Subject: [PATCH 303/336] Widgets: Revert On This Day dashboard widget. The On This Day dashboard widget has been bumped from the WordPress 7.1 release to the 7.2 release pending design and behavioural improvements. This reverts r62977, r62968, r62852, r62681. Props annezazu, matt, peterwilsoncc. Fixes #65801. git-svn-id: https://develop.svn.wordpress.org/trunk@63001 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/dashboard.css | 29 -- .../includes/dashboard-on-this-day.php | 223 -------- src/wp-admin/includes/dashboard.php | 7 - .../tests/admin/wpDashboardOnThisDay.php | 477 ------------------ 4 files changed, 736 deletions(-) delete mode 100644 src/wp-admin/includes/dashboard-on-this-day.php delete mode 100644 tests/phpunit/tests/admin/wpDashboardOnThisDay.php diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index 860fe9696b873..17a9e312b85c6 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -1025,35 +1025,6 @@ body #dashboard-widgets .postbox form .submit { top: 0; } -/* On This Day dashboard widget */ - -#wp_dashboard_on_this_day h3 { - font-weight: 600; -} - -#wp_dashboard_on_this_day li { - margin: 0; - padding: 0; -} - -#wp_dashboard_on_this_day ul ul { - margin: 0 0 0 18px; - padding: 0; - list-style: disc; -} - -#wp_dashboard_on_this_day ul ul li + li { - margin-top: 6px; -} - -#wp_dashboard_on_this_day .wp-on-this-day-widget > ul > li + li { - margin-top: 16px; -} - -#wp_dashboard_on_this_day .wp-on-this-day-post-author { - color: #646970; -} - /* Browse happy box */ #dashboard-widgets #dashboard_browser_nag.postbox .inside { diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php deleted file mode 100644 index 948e128f72c59..0000000000000 --- a/src/wp-admin/includes/dashboard-on-this-day.php +++ /dev/null @@ -1,223 +0,0 @@ -'; - - if ( current_user_can( 'edit_posts' ) ) { - printf( - /* translators: %s: URL to the new post screen. */ - __( 'No posts were published on this day in previous years. Write one today, and be reminded about it next year.' ), - esc_url( admin_url( 'post-new.php' ) ) - ); - } else { - echo esc_html__( 'No posts were published on this day in previous years.' ); - } - - echo '

      '; - return; - } - - $posts_by_year = array(); - $post_count = count( $posts ); - - foreach ( $posts as $post ) { - $year = get_the_date( 'Y', $post ); - - if ( ! isset( $posts_by_year[ $year ] ) ) { - $posts_by_year[ $year ] = array(); - } - - $posts_by_year[ $year ][] = $post; - } - - /* translators: Date format for the On This Day widget date, without year. See https://www.php.net/manual/datetime.format.php */ - $date = '' . esc_html( wp_date( _x( 'F jS', 'on this day date format' ) ) ) . ''; - ?> -
      -

      - -

      -
        - $year_posts ) : ?> -
      • -

        -
          - - ID ) && ! post_password_required( $year_post ) ) { - $excerpt = get_the_excerpt( $year_post ); - - if ( is_string( $excerpt ) && '' !== $excerpt ) { - $no_title_excerpt = wp_trim_words( $excerpt, 15 ); - } - } - } - - $author_id = (int) $year_post->post_author; - $author_name = $author_id > 0 ? (string) get_the_author_meta( 'display_name', $author_id ) : ''; - $show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id; - ?> -
        • - - - - - - ' . esc_html( - sprintf( - /* translators: %s: Post author's display name. */ - __( 'by %s' ), - $author_name - ) - ) . ''; - ?> - -
        • - -
        -
      • - -
      -
      - format( 'Y' ); - $date_query = array( - 'relation' => 'AND', - array( - 'before' => array( 'year' => $year ), - ), - _wp_dashboard_on_this_day_date_query_clause( $today ), - ); - - $args = array( - 'post_type' => 'post', - 'post_status' => array( 'publish' ), - 'posts_per_page' => 10, - 'ignore_sticky_posts' => true, - 'orderby' => 'date', - 'order' => 'DESC', - 'no_found_rows' => true, - 'update_post_term_cache' => false, - 'update_post_meta_cache' => false, - 'date_query' => $date_query, - ); - - /** - * Filters the arguments used to query posts for the On This Day dashboard widget. - * - * @since 7.1.0 - * - * @param array $args WP_Query arguments. - */ - $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args ); - - $query = new WP_Query( $args ); - - return $query->posts; -} - -/** - * Builds the date query clause for today's anniversary date. - * - * On February 28 in a non-leap year, February 29 posts are included so - * leap-day anniversaries still appear. - * - * @since 7.1.0 - * @access private - * - * @param DateTimeInterface $date Date to build the clause for. - * @return array Date query clause. - */ -function _wp_dashboard_on_this_day_date_query_clause( $date ) { - $month = (int) $date->format( 'm' ); - $day = (int) $date->format( 'd' ); - $clause = array( - 'month' => $month, - 'day' => $day, - ); - - // Display leap day posts on Feb 28 in non leap years. - if ( - 28 === $day - && 2 === $month - && false === (bool) $date->format( 'L' ) - ) { - $clause = array( - 'relation' => 'OR', - $clause, - array( - 'month' => 2, - 'day' => 29, - ), - ); - } - - return $clause; -} diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index a0c2a23189644..0fe5c62064b64 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -88,13 +88,6 @@ function wp_dashboard_setup() { wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' ); } - // On This Day. - if ( ! function_exists( 'wp_dashboard_on_this_day' ) ) { - require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php'; - } - - wp_add_dashboard_widget( 'wp_dashboard_on_this_day', __( 'On This Day' ), 'wp_dashboard_on_this_day' ); - // WordPress Events and News. wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' ); diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php deleted file mode 100644 index 728b9bcef64d0..0000000000000 --- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php +++ /dev/null @@ -1,477 +0,0 @@ -user->create( - array( - 'display_name' => 'Current Writer', - 'role' => 'author', - ) - ); - self::$other_user_id = $factory->user->create( - array( - 'display_name' => 'Guest Writer', - 'role' => 'author', - ) - ); - self::$subscriber_id = $factory->user->create( - array( - 'display_name' => 'Reader', - 'role' => 'subscriber', - ) - ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$user_id ); - self::delete_user( self::$other_user_id ); - self::delete_user( self::$subscriber_id ); - } - - public function set_up() { - parent::set_up(); - - set_current_screen( 'dashboard' ); - } - - /** - * Creates a published post on the widget's prior-year calendar day. - * - * @param int $author_id Author ID. - * @param string $title Post title. - * @param int $years_ago Number of years before today. - * @param string $time Post time. - * @param array $post_args Additional post arguments. - * @return int Post ID. - */ - private function create_matching_post( - int $author_id, - string $title = 'A memory from last year', - int $years_ago = 1, - string $time = '12:00:00', - array $post_args = array() - ): int { - $post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time; - - return self::factory()->post->create( - array_merge( - array( - 'post_author' => $author_id, - 'post_date' => $post_date, - 'post_date_gmt' => get_gmt_from_date( $post_date ), - 'post_status' => 'publish', - 'post_title' => $title, - ), - $post_args - ) - ); - } - - /** - * Creates a published post near, but not on, today's prior-year calendar day. - * - * @param int $author_id Author ID. - * @param string $title Post title. - * @param int $day_offset Number of days from today's prior-year calendar day. - * @return int Post ID. - */ - private function create_nearby_post( int $author_id, string $title = 'Almost a memory', int $day_offset = 1 ): int { - $post_date = current_datetime() - ->modify( '-1 year' ) - ->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' ) - ->format( 'Y-m-d' ) . ' 12:00:00'; - - return self::factory()->post->create( - array( - 'post_author' => $author_id, - 'post_date' => $post_date, - 'post_date_gmt' => get_gmt_from_date( $post_date ), - 'post_status' => 'publish', - 'post_title' => $title, - ) - ); - } - - /** - * Invokes _wp_dashboard_on_this_day_date_query_clause(). - * - * @param string $date Date string. - * @return array Date query clause. - */ - private static function get_date_query_clause( string $date ): array { - return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) ); - } - - /** - * @ticket 65116 - * - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() { - $clause = self::get_date_query_clause( '2023-02-28 12:00:00' ); - - $this->assertSame( - array( - 'relation' => 'OR', - array( - 'month' => 2, - 'day' => 28, - ), - array( - 'month' => 2, - 'day' => 29, - ), - ), - $clause - ); - } - - /** - * @ticket 65116 - * - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() { - $clause = self::get_date_query_clause( '2024-02-28 12:00:00' ); - - $this->assertSame( - array( - 'month' => 2, - 'day' => 28, - ), - $clause - ); - } - - /** - * @ticket 65116 - * - * @covers ::_wp_dashboard_on_this_day_date_query_clause - */ - public function test_get_date_query_clause_matches_february_29_on_leap_day() { - $clause = self::get_date_query_clause( '2024-02-29 12:00:00' ); - - $this->assertSame( - array( - 'month' => 2, - 'day' => 29, - ), - $clause - ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_outputs_placeholder_without_matching_posts() { - wp_set_current_user( self::$user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); - $this->assertStringContainsString( 'Write one today', $output ); - $this->assertStringContainsString( admin_url( 'post-new.php' ), $output ); - $this->assertStringNotContainsString( '
        ', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_placeholder_omits_link_without_edit_posts_capability() { - wp_set_current_user( self::$subscriber_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); - $this->assertStringNotContainsString( 'Write one today', $output ); - $this->assertStringNotContainsString( admin_url( 'post-new.php' ), $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_ignores_nearby_prior_year_posts() { - wp_set_current_user( self::$user_id ); - $this->create_nearby_post( self::$user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringNotContainsString( 'Almost a memory', $output ); - $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_uses_singular_copy_for_a_single_post() { - wp_set_current_user( self::$user_id ); - $this->create_matching_post( self::$user_id ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'One post has been published on ' . wp_date( 'F jS' ) . ':', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_labels_posts_from_other_authors() { - wp_set_current_user( self::$user_id ); - - $this->create_matching_post( self::$user_id, 'A note from me' ); - $this->create_matching_post( self::$other_user_id, 'A note from someone else' ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'A note from me', $output ); - $this->assertStringNotContainsString( 'by Current Writer', $output ); - $this->assertStringContainsString( 'A note from someone else', $output ); - $this->assertStringContainsString( 'by Guest Writer', $output ); - $this->assertStringContainsString( '', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_groups_posts_by_year() { - wp_set_current_user( self::$user_id ); - - $this->create_matching_post( self::$user_id, 'Pretending to meditate', 1, '12:00:00' ); - $this->create_matching_post( self::$user_id, 'Slow internet and good books', 1, '11:00:00' ); - $this->create_matching_post( self::$user_id, 'Late-night shipping log', 2, '12:00:00' ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $last_year = current_datetime()->modify( '-1 year' )->format( 'Y' ); - $two_years_ago = current_datetime()->modify( '-2 years' )->format( 'Y' ); - - $this->assertStringContainsString( '3 posts have been published on ' . wp_date( 'F jS' ) . ':', $output ); - $this->assertStringContainsString( '

        ' . $last_year . '

        ', $output ); - $this->assertStringContainsString( '

        ' . $two_years_ago . '

        ', $output ); - $this->assertStringContainsString( 'Pretending to meditate', $output ); - $this->assertStringContainsString( 'Slow internet and good books', $output ); - $this->assertStringContainsString( 'Late-night shipping log', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_includes_trimmed_excerpt_for_untitled_posts() { - wp_set_current_user( self::$user_id ); - - $words = array(); - for ( $n = 1; $n <= 20; $n++ ) { - $words[] = 'word' . $n; - } - - $this->create_matching_post( - self::$user_id, - '', - 1, - '12:00:00', - array( - 'post_excerpt' => implode( ' ', $words ), - ) - ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( '(no title)', $output ); - $this->assertStringContainsString( 'word15', $output, 'The 15th word should be present.' ); - $this->assertStringNotContainsString( 'word16', $output, 'The 16th word should be trimmed.' ); - $this->assertStringContainsString( '…', $output, 'The excerpt should end with an ellipsis.' ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_does_not_append_excerpt_to_titled_posts() { - wp_set_current_user( self::$user_id ); - - $this->create_matching_post( - self::$user_id, - 'A titled anniversary memory', - 1, - '12:00:00', - array( - 'post_excerpt' => 'This excerpt should not be shown.', - ) - ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'A titled anniversary memory', $output ); - $this->assertStringNotContainsString( 'This excerpt should not be shown.', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() { - wp_set_current_user( self::$user_id ); - - $this->create_matching_post( - self::$user_id, - '', - 1, - '12:00:00', - array( - 'post_excerpt' => 'Readable private anniversary memory.', - 'post_status' => 'private', - ) - ); - - add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); - - ob_start(); - try { - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - } finally { - remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); - } - - $this->assertStringContainsString( '(no title)', $output ); - $this->assertStringContainsString( 'Readable private anniversary memory.', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() { - wp_set_current_user( self::$user_id ); - - $post_id = $this->create_matching_post( - self::$other_user_id, - '', - 1, - '12:00:00', - array( - 'post_excerpt' => 'Unreadable private anniversary memory.', - 'post_status' => 'private', - ) - ); - - add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); - - ob_start(); - try { - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - } finally { - remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); - } - - $this->assertFalse( current_user_can( 'read_post', $post_id ) ); - $this->assertStringContainsString( '(no title)', $output ); - $this->assertStringNotContainsString( 'Unreadable private anniversary memory.', $output ); - } - - /** - * @ticket 65116 - * - * @covers ::wp_dashboard_on_this_day - */ - public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() { - wp_set_current_user( self::$user_id ); - - $this->create_matching_post( - self::$user_id, - '', - 1, - '12:00:00', - array( - 'post_excerpt' => 'Private anniversary memory.', - 'post_password' => 'secret', - ) - ); - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringNotContainsString( 'Private anniversary memory.', $output ); - } - - /** - * @covers ::wp_dashboard_on_this_day - * @covers ::wp_dashboard_on_this_day_get_posts - */ - public function test_widget_limits_posts_to_ten() { - wp_set_current_user( self::$user_id ); - - for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) { - $this->create_matching_post( self::$user_id, 'Anniversary post ' . $years_ago, $years_ago ); - } - - ob_start(); - wp_dashboard_on_this_day(); - $output = ob_get_clean(); - - $this->assertStringContainsString( '10 posts have been published on ' . wp_date( 'F jS' ) . ':', $output ); - $this->assertMatchesRegularExpression( '/>\s*Anniversary post 1\s*<\/a>/', $output ); - $this->assertMatchesRegularExpression( '/>\s*Anniversary post 10\s*<\/a>/', $output ); - $this->assertStringNotContainsString( 'Anniversary post 11', $output ); - } - - /** - * Filters the On This Day query to include private posts. - * - * @param array $args WP_Query arguments. - * @return array Filtered query arguments. - */ - public function filter_on_this_day_query_private_posts( $args ) { - $args['post_status'] = array( 'private' ); - - return $args; - } -} From c0155cd2bea0722270b5f15b2a9ce78b2d78e375 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 01:56:04 +0000 Subject: [PATCH 304/336] Media: Normalize unusable `sizes` attachment metadata. Attachment metadata is untyped, and the `sizes` key is not guaranteed to be present or to hold an array. Sub-size generation can leave it out entirely, and a plugin filtering `wp_get_attachment_metadata` can replace it with anything. `wp_save_image()` validated only that the metadata itself was an array before passing `$meta['sizes']` to `array_merge()`, so an absent or scalar value raised a `TypeError` and the image editor returned an HTTP 500 mid-save. `wp_restore_image()` had the same gap at `$meta['sizes'][ $default_size ] = $data`, where a string raises "Cannot use a scalar value as an array" and `false` is deprecated as of PHP 8.1 and an error as of PHP 9. `wp_get_attachment_metadata()` now returns `false` whenever the metadata is not an array, on the `$unfiltered` path as well as after the filter, matching the documented `array|false` return. A `sizes` key holding a non-array is replaced with an empty array, so every caller can rely on the key being an array whenever it is present. The key is not invented when it is absent: audio, video and document attachments legitimately store metadata without it, and callers such as `wp-admin/post.php` read the metadata unfiltered in order to modify it and write it back, so normalizing there would persist into the database. The image editor entry points fill in the missing key themselves, and `wp_prepare_attachment_for_js()` now checks the dimensions of the `full` entry alongside its filename before reading them, removing the "Undefined array key" warnings raised for a `sizes` array that carries no usable `full` size. PHPUnit coverage is added for all three functions. Developed in https://github.com/WordPress/wordpress-develop/pull/12744. Follow-up to r11965, r23873, r38949, r49084, r62978. Props josephscott, westonruter, mukesh27, irozum, ugyensupport, nazmulasif. See #65686, #64898. Fixes #65748. git-svn-id: https://develop.svn.wordpress.org/trunk@63002 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/image-edit.php | 6 +- src/wp-includes/media.php | 5 +- src/wp-includes/post.php | 16 +- .../phpunit/tests/ajax/wpAjaxImageEditor.php | 132 ++++++++ tests/phpunit/tests/media.php | 150 ++++++++++ .../tests/post/wpGetAttachmentMetadata.php | 281 ++++++++++++++++++ 6 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/tests/post/wpGetAttachmentMetadata.php diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index a192ef0000c17..2f6bc25740e2d 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -820,11 +820,13 @@ function wp_restore_image( $post_id ) { $restored = false; $msg = new stdClass(); - if ( ! is_array( $backup_sizes ) ) { + if ( ! is_array( $meta ) || ! is_array( $backup_sizes ) ) { $msg->error = __( 'Cannot load image metadata.' ); return $msg; } + $meta['sizes'] ??= array(); + $parts = pathinfo( $file ); $suffix = time() . rand( 100, 999 ); $default_sizes = get_intermediate_image_sizes(); @@ -983,6 +985,8 @@ function wp_save_image( $post_id ) { return $return; } + $meta['sizes'] ??= array(); + if ( ! is_array( $backup_sizes ) ) { $backup_sizes = array(); } diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 9f98538d2757f..a31e77b00e26c 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4814,7 +4814,10 @@ function wp_prepare_attachment_for_js( $attachment ) { } $response = array_merge( $response, $sizes['full'] ); - } elseif ( $meta['sizes']['full']['file'] ) { + } elseif ( + ! empty( $meta['sizes']['full']['file'] ) && + isset( $meta['sizes']['full']['width'], $meta['sizes']['full']['height'] ) + ) { $sizes['full'] = array( 'url' => esc_url_raw( $base_url . $meta['sizes']['full']['file'] ), 'height' => $meta['sizes']['full']['height'], diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index da3abfbd7d61c..2db73e9a20476 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -7048,6 +7048,8 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * * @since 2.1.0 * @since 6.0.0 The `$filesize` value was added to the returned array. + * @since 7.1.0 `false` is now returned if the metadata is not an array, and when the result is + * filtered the `sizes` key is always an array when present. * * @param int $attachment_id Attachment post ID. Defaults to global $post. * @param bool $unfiltered Optional. If true, filters are not run. Default false. @@ -7111,7 +7113,7 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { $data = get_post_meta( $attachment_id, '_wp_attachment_metadata', true ); - if ( ! $data ) { + if ( ! is_array( $data ) || ! $data ) { return false; } @@ -7127,7 +7129,17 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { * @param array $data Array of meta data for the given attachment. * @param int $attachment_id Attachment post ID. */ - return apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id ); + $data = apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id ); + + if ( ! is_array( $data ) ) { + return false; + } + + if ( array_key_exists( 'sizes', $data ) && ! is_array( $data['sizes'] ) ) { + $data['sizes'] = array(); + } + + return $data; } /** diff --git a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php index 205f61636149c..03f14f6dd8fa8 100644 --- a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php +++ b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php @@ -194,4 +194,136 @@ public function test_filesize_restored_after_restoring_original_image() { $this->assertSameSetsWithIndex( $pre_file_sizes, $post_restore_file_sizes, 'Filesize should have restored after restoring the original image.' ); } + + /** + * Ensure editing an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * Attachment metadata is not guaranteed to contain a `sizes` array. It can be missing when + * sub-size generation never ran or failed (for example `wp_create_image_subsizes()` returns an + * empty array when the file cannot be parsed), or when it is removed by a plugin filtering + * `wp_get_attachment_metadata`. `wp_save_image()` only validates that the metadata itself is an + * array, then passes `$meta['sizes']` straight to `array_merge()`. + * + * @ticket 65748 + * + * @covers ::wp_save_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array{ sizes?: mixed } $meta Attachment metadata to store before editing, minus the file-specific keys. + */ + public function test_save_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + $this->assertIsString( $contents ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + $this->assertIsInt( $id ); + + $original_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $original_meta ); + + // Keep the real file/dimension data, only make `sizes` unusable. + $meta = array_merge( + wp_array_slice_assoc( $original_meta, array( 'width', 'height', 'file', 'filesize' ) ), + $meta + ); + + wp_update_attachment_metadata( $id, $meta ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + $ret = wp_save_image( $id ); + + $this->assertObjectNotHasProperty( 'error', $ret, 'Saving the image should not have returned an error.' ); + + $saved_meta = wp_get_attachment_metadata( $id ); + + $this->assertIsArray( $saved_meta, 'The saved attachment metadata should be an array.' ); + $this->assertArrayHasKey( 'sizes', $saved_meta ); + $this->assertIsArray( $saved_meta['sizes'], 'The saved attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $saved_meta['sizes'], 'The edited image should have regenerated the thumbnail size.' ); + } + + /** + * Ensure restoring an image does not fatal when the attachment metadata has no usable `sizes` data. + * + * `wp_restore_image()` writes each backed up size with `$meta['sizes'][ $default_size ] = $data` + * without ever checking that `$meta['sizes']` is an array. A scalar value raises + * "Cannot use a scalar value as an array", and `false` is deprecated as of PHP 8.1 and + * an error as of PHP 9. The same metadata that fatals `wp_save_image()` reaches this code. + * + * @ticket 65748 + * + * @covers ::wp_restore_image + * + * @dataProvider data_save_image_with_unusable_sizes_metadata + * + * @param array{ sizes?: mixed } $meta Replacement `sizes` metadata to store before restoring. + */ + public function test_restore_image_with_unusable_sizes_metadata( array $meta ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $filename = DIR_TESTDATA . '/images/canola.jpg'; + $contents = file_get_contents( $filename ); + $this->assertIsString( $contents ); + + $upload = wp_upload_bits( wp_basename( $filename ), null, $contents ); + $id = $this->_make_attachment( $upload ); + $this->assertIsInt( $id ); + + $_REQUEST['action'] = 'image-editor'; + $_REQUEST['context'] = 'edit-attachment'; + $_REQUEST['postid'] = $id; + $_REQUEST['target'] = 'all'; + $_REQUEST['do'] = 'save'; + $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]'; + + // Edit the image first so that `_wp_attachment_backup_sizes` holds the original sizes. + wp_save_image( $id ); + + $this->assertNotEmpty( + get_post_meta( $id, '_wp_attachment_backup_sizes', true ), + 'The image edit should have stored backup sizes to restore from.' + ); + + // Keep the metadata written by the edit, only make `sizes` unusable. + $edited_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $edited_meta ); + unset( $edited_meta['sizes'] ); + + wp_update_attachment_metadata( $id, array_merge( $edited_meta, $meta ) ); + + wp_restore_image( $id ); + + $restored_meta = wp_get_attachment_metadata( $id ); + $this->assertIsArray( $restored_meta ); + + $this->assertArrayHasKey( 'sizes', $restored_meta ); + $this->assertIsArray( $restored_meta['sizes'], 'The restored attachment metadata should contain a `sizes` array.' ); + $this->assertArrayHasKey( 'thumbnail', $restored_meta['sizes'], 'The restored image should have the thumbnail size restored from the backup sizes.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_save_image_with_unusable_sizes_metadata(): array { + return array( + 'no sizes key' => array( array() ), + 'null sizes' => array( array( 'sizes' => null ) ), + 'empty string' => array( array( 'sizes' => '' ) ), + 'string sizes' => array( array( 'sizes' => 'not-an-array' ) ), + 'boolean sizes' => array( array( 'sizes' => false ) ), + ); + } } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index a492cf6da189f..5aee3f8b6955f 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -667,6 +667,153 @@ public function test_wp_prepare_attachment_for_js_without_image_sizes() { $this->assertArrayHasKey( 'sizes', $prepped ); } + /** + * Tests that an unusable `full` entry in the `sizes` metadata is skipped. + * + * Attachments that are not images, such as PDFs, are handled by a separate branch that reads + * the `full` entry of the `sizes` metadata directly. That entry is not guaranteed to be there, + * nor to carry dimensions when it is, and reading it unconditionally raises "Undefined array + * key" warnings. + * + * @ticket 65748 + * + * @dataProvider data_wp_prepare_attachment_for_js_unusable_full_size + * + * @covers ::wp_prepare_attachment_for_js + * + * @param array $sizes Value to store as the `sizes` metadata. + */ + public function test_wp_prepare_attachment_for_js_with_an_unusable_full_size( array $sizes ) { + $id = $this->create_pdf_attachment( $sizes ); + + $prepped = wp_prepare_attachment_for_js( $id ); + + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'sizes', $prepped ); + + $sizes = $prepped['sizes']; + + $this->assertIsArray( $sizes ); + $this->assertArrayNotHasKey( 'full', $sizes, 'An unusable `full` size should not have been exposed.' ); + } + + /** + * Tests that a usable `full` entry in the `sizes` metadata is still exposed. + * + * @ticket 65748 + * + * @covers ::wp_prepare_attachment_for_js + */ + public function test_wp_prepare_attachment_for_js_with_a_usable_full_size() { + $id = $this->create_pdf_attachment( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'width' => 232, + 'height' => 300, + 'mime-type' => 'image/jpeg', + ), + ) + ); + + $prepped = wp_prepare_attachment_for_js( $id ); + + $this->assertIsArray( $prepped ); + $this->assertArrayHasKey( 'sizes', $prepped ); + + $sizes = $prepped['sizes']; + + $this->assertIsArray( $sizes ); + $this->assertArrayHasKey( 'full', $sizes, 'A usable `full` size should have been exposed.' ); + + $full = $sizes['full']; + + $this->assertIsArray( $full ); + $this->assertSame( 232, $full['width'] ); + $this->assertSame( 300, $full['height'] ); + $this->assertSame( 'portrait', $full['orientation'] ); + $this->assertIsString( $full['url'] ); + $this->assertStringEndsWith( '/test-document-pdf.jpg', $full['url'] ); + } + + /** + * Data provider. + * + * @return array }> + */ + public function data_wp_prepare_attachment_for_js_unusable_full_size(): array { + return array( + 'no full size' => array( + array( + 'thumbnail' => array( + 'file' => 'test-document-pdf-116x150.jpg', + 'width' => 116, + 'height' => 150, + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full without dimensions' => array( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full without a height' => array( + array( + 'full' => array( + 'file' => 'test-document-pdf.jpg', + 'width' => 232, + 'mime-type' => 'image/jpeg', + ), + ), + ), + 'full with an empty file' => array( + array( + 'full' => array( + 'file' => '', + 'width' => 232, + 'height' => 300, + 'mime-type' => 'image/jpeg', + ), + ), + ), + ); + } + + /** + * Creates a PDF attachment carrying the given `sizes` metadata. + * + * A PDF is used so that wp_prepare_attachment_for_js() takes the branch for attachments that + * are not images, which is the one that reads the `full` entry of the `sizes` metadata. + * + * @param array $sizes Value to store as the `sizes` metadata. + * @return int Attachment ID. + */ + private function create_pdf_attachment( array $sizes ): int { + $id = wp_insert_attachment( + array( + 'post_title' => 'Attachment Title', + 'post_type' => 'attachment', + 'post_parent' => 0, + 'post_mime_type' => 'application/pdf', + 'guid' => home_url( '/wp-content/uploads/test-document.pdf' ), + ) + ); + + wp_update_attachment_metadata( + $id, + array( + 'file' => 'test-document.pdf', + 'sizes' => $sizes, + ) + ); + + return $id; + } + /** * Tests that a `filesize` stored in the attachment metadata is normalized to a positive integer. * @@ -3178,6 +3325,9 @@ public function test_get_image_send_to_editor_defaults_no_caption_no_rel() { * * @ticket 36246 * @requires function imagejpeg + * + * @covers ::wp_get_attachment_image + * @covers ::wp_get_attachment_metadata */ public function test_wp_get_attachment_image_should_use_wp_get_attachment_metadata() { add_filter( 'wp_get_attachment_metadata', array( $this, 'filter_36246' ), 10, 2 ); diff --git a/tests/phpunit/tests/post/wpGetAttachmentMetadata.php b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php new file mode 100644 index 0000000000000..028356a04168d --- /dev/null +++ b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php @@ -0,0 +1,281 @@ +create_attachment(); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure stored metadata that is not an array is reported as a failure. + * + * The documented return of `array|false` has to hold on the `$unfiltered` path too, since + * callers such as wp-admin/post.php read the metadata that way in order to modify it and + * pass it back to wp_update_attachment_metadata(). + * + * @ticket 65748 + * + * @dataProvider data_non_array_stored_metadata_values + * + * @param mixed $metadata Value to store as `_wp_attachment_metadata`. + */ + public function test_should_return_false_when_the_stored_metadata_is_not_an_array( $metadata ) { + $attachment_id = $this->create_attachment(); + + update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata ); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ), 'The filtered metadata should have been reported as missing.' ); + $this->assertFalse( wp_get_attachment_metadata( $attachment_id, true ), 'The unfiltered metadata should have been reported as missing.' ); + } + + /** + * Ensure the `sizes` key is not invented for attachments that have no sub-sizes. + * + * An attachment is not necessarily an image. Audio, video and document attachments + * legitimately store metadata without a `sizes` key, and fabricating one would both + * blur that distinction and pollute the stored metadata for any caller that reads + * the metadata, modifies it, and passes it back to wp_update_attachment_metadata(). + * + * @ticket 65748 + */ + public function test_should_not_add_a_sizes_key_when_the_metadata_has_none() { + $metadata = array( + 'bitrate' => 128000, + 'length' => 191, + 'fileformat' => 'mp3', + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure a usable `sizes` array is passed through untouched. + * + * @ticket 65748 + */ + public function test_should_preserve_a_usable_sizes_array() { + $metadata = array( + 'file' => '2026/08/image.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'file' => 'image-150x150.jpg', + 'width' => 150, + 'height' => 150, + 'mime-type' => 'image/jpeg', + ), + ), + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure a `sizes` key holding something other than an array is replaced with an empty array. + * + * Callers such as wp_save_image() pass `$meta['sizes']` straight to array_merge(), which + * is a fatal error for a scalar. Guarding the value here means every caller can rely on + * `sizes` being an array whenever the key is present. + * + * @ticket 65748 + * + * @dataProvider data_non_array_sizes_values + * + * @param mixed $sizes Value to store under the `sizes` key. + */ + public function test_should_replace_a_non_array_sizes_value_with_an_empty_array( $sizes ) { + $attachment_id = $this->create_attachment( + array( + 'file' => '2026/08/image.jpg', + 'sizes' => $sizes, + ) + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' ); + $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' ); + $this->assertSame( array(), $metadata['sizes'], 'The unusable `sizes` value should have been replaced.' ); + } + + /** + * Ensure the stored metadata is returned verbatim when filters are skipped. + * + * Passing `$unfiltered` as true is documented as skipping the filters, and callers such as + * wp-admin/post.php read the metadata this way in order to modify and re-save it. Normalizing + * the value here would write the normalization back into the database. + * + * @ticket 65748 + * + * @dataProvider data_non_array_sizes_values + * + * @param mixed $sizes Value to store under the `sizes` key. + */ + public function test_should_not_replace_a_non_array_sizes_value_when_unfiltered( $sizes ) { + $metadata = array( + 'file' => '2026/08/image.jpg', + 'sizes' => $sizes, + ); + + $attachment_id = $this->create_attachment( $metadata ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) ); + } + + /** + * Ensure a filtered value that is not an array is reported as a failure. + * + * The function documents a return of `array|false`, so a filter returning something else + * should surface as a failure rather than being handed to callers that expect an array. + * + * @ticket 65748 + * + * @dataProvider data_non_array_filter_return_values + * + * @param mixed $value Value for the filter to return. + */ + public function test_should_return_false_when_the_filter_returns_a_non_array( $value ) { + $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) ); + + add_filter( + 'wp_get_attachment_metadata', + static function () use ( $value ) { + return $value; + } + ); + + $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) ); + } + + /** + * Ensure the `sizes` value is normalized after the filter has run, not before. + * + * @ticket 65748 + */ + public function test_should_normalize_a_sizes_value_introduced_by_the_filter() { + // Stored without a `sizes` key, so the key can only come from the filter. + $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) ); + + add_filter( + 'wp_get_attachment_metadata', + static function ( array $data ): array { + $data['sizes'] = 'not-an-array'; + return $data; + } + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' ); + $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' ); + $this->assertSame( array(), $metadata['sizes'], 'The value set by the filter should have been replaced.' ); + } + + /** + * Ensure the filter is not applied when filters are skipped. + */ + public function test_should_not_apply_the_filter_when_unfiltered() { + $metadata = array( 'file' => '2026/08/image.jpg' ); + + $attachment_id = $this->create_attachment( $metadata ); + + add_filter( 'wp_get_attachment_metadata', '__return_empty_array' ); + + $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) ); + } + + /** + * Data provider. + * + * Only values that survive a round trip through the meta table are listed. A value that + * comes back falsy, such as an empty string, was already treated as missing metadata. + * + * @return array + */ + public function data_non_array_stored_metadata_values(): array { + return array( + 'string' => array( 'not-an-array' ), + 'integer' => array( 1 ), + 'float' => array( 1.5 ), + 'object' => array( new stdClass() ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_non_array_sizes_values(): array { + return array( + 'null' => array( null ), + 'empty string' => array( '' ), + 'string' => array( 'not-an-array' ), + 'boolean false' => array( false ), + 'integer' => array( 0 ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_non_array_filter_return_values(): array { + return array( + 'null' => array( null ), + 'empty string' => array( '' ), + 'string' => array( 'not-an-array' ), + 'boolean false' => array( false ), + 'boolean true' => array( true ), + 'integer' => array( 1 ), + 'float' => array( 1.5 ), + 'object' => array( new stdClass() ), + ); + } + + /** + * Creates an attachment, optionally storing metadata for it. + * + * The metadata is stored with update_post_meta() rather than wp_update_attachment_metadata() + * so that it reaches the database without passing through the update filter, leaving the + * stored value entirely under the control of the test. + * + * @param array|null $metadata Optional. Metadata to store as + * `_wp_attachment_metadata`. Default null, meaning + * no metadata is stored at all. + * @return int Attachment ID. + */ + private function create_attachment( ?array $metadata = null ): int { + $attachment_id = self::factory()->attachment->create_object( + array( + 'file' => '2026/08/image.jpg', + 'post_mime_type' => 'image/jpeg', + ) + ); + + $this->assertIsInt( $attachment_id, 'Failed to create the attachment fixture.' ); + + if ( null !== $metadata ) { + update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata ); + } + + return $attachment_id; + } +} From 275a37a6b1663031f01ff4481acf0aec5bb4bc0c Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Tue, 4 Aug 2026 02:03:30 +0000 Subject: [PATCH 305/336] Build/Test Tools: Further refine runner override variable name. This changes `RUNNER_GROUP` to `RUNNERS_NAME` to avoid confusion with the `runs-on.group` setting, which is configured in a completely different way. Props lancewillet. See #65749. git-svn-id: https://develop.svn.wordpress.org/trunk@63003 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index abe472e03b6d1..6a7f49fba468c 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -129,7 +129,7 @@ jobs: # - Submit the test results to the WordPress.org host test results. phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} - runs-on: ${{ vars.RUNNER_GROUP || inputs.os }} + runs-on: ${{ vars.RUNNERS_NAME || inputs.os }} timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read From 8c3c976c251d4fd2225b6eaa788b0678c1927206 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 02:50:15 +0000 Subject: [PATCH 306/336] Build/Test Tools: Expand suggested extensions in `composer.json`. The `suggest` section had accumulated only four extensions, added ad hoc as individual changes happened to need them. It now lists every extension that the Hosting handbook's server environment page [https://make.wordpress.org/hosting/handbook/server-environment/#required-extensions identifies] as required, highly recommended, or suggested. This lets development environments be provisioned to match what core actually expects, and gives IDEs an accurate picture of which functions are available. The `require` section is deliberately left unchanged: the `mysqli` extension stays a suggestion rather than a requirement since a `db.php` drop-in can supply the database layer without it. Developed in https://github.com/WordPress/wordpress-develop/pull/12384. Follow-up to r56687, r62529, r62637. Fixes #65571. git-svn-id: https://develop.svn.wordpress.org/trunk@63004 602fd350-edb4-49c9-b593-d223f7449a82 --- composer.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5505c9136c263..1bff1b4d62dd7 100644 --- a/composer.json +++ b/composer.json @@ -16,10 +16,35 @@ "php": ">=7.4" }, "suggest": { + "ext-apcu": "*", + "ext-bc": "*", + "ext-curl": "*", "ext-dom": "*", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-filter": "*", "ext-ftp": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-igbinary": "*", + "ext-imagick": "*", + "ext-intl": "*", + "ext-mbstring": "*", + "ext-memcached": "*", "ext-mysqli": "*", - "ext-ssh2": "*" + "ext-opcache": "*", + "ext-openssl": "*", + "ext-redis": "*", + "ext-shmop": "*", + "ext-simplexml": "*", + "ext-sockets": "*", + "ext-sodium": "*", + "ext-ssh2": "*", + "ext-timezonedb": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "ext-zlib": "*" }, "require-dev": { "composer/ca-bundle": "1.5.13", From 0040ded7216de5597637f81b3117d119b736160b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 05:13:18 +0000 Subject: [PATCH 307/336] Build/Test Tools: Add `@phpstan-assert` on `assertIXRError` and `assertNotIXRError`. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@63005 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/includes/abstract-testcase.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 55a9924fb23c3..98b3456935716 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -898,6 +898,8 @@ public function assertNotWPError( $actual, $message = '' ) { * * @param mixed $actual The value to check. * @param string $message Optional. Message to display when the assertion fails. + * + * @phpstan-assert IXR_Error $actual */ public function assertIXRError( $actual, $message = '' ) { $this->assertInstanceOf( 'IXR_Error', $actual, $message ); @@ -908,6 +910,8 @@ public function assertIXRError( $actual, $message = '' ) { * * @param mixed $actual The value to check. * @param string $message Optional. Message to display when the assertion fails. + * + * @phpstan-assert !IXR_Error $actual */ public function assertNotIXRError( $actual, $message = '' ) { if ( $actual instanceof IXR_Error ) { From 1ef9d70aea32f272b3680408d7c4761c8ca32445 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 07:25:22 +0000 Subject: [PATCH 308/336] XML-RPC: Validate the attachment data in `mw_newMediaObject()`. Passing anything other than a struct as the fourth argument caused a fatal error, and because the struct was read before the login was attempted, an unauthenticated request was enough to trigger it. Read and validate the struct only once the request is authenticated and the `upload_files` capability is confirmed, as every other method on the server does, and reject a call with too few arguments using `minimum_args()`. The `name`, `type` and `bits` members must all be strings: a struct sent for `bits` reached `fwrite()` by way of `wp_upload_bits()` and threw a `TypeError`, while one sent for `type` survived `sanitize_mime_type()` to reach the database as the attachment's post MIME type. A `name` left empty by `sanitize_file_name()` is now reported as a malformed request too, rather than as the server failure `wp_upload_bits()` produced for it. The fourth argument is expanded into a nested hash in the documentation, covering the previously undocumented `post_id` member. Tests cover each rejected shape, the optional members that remain tolerated when absent, and the ordering of the login and capability checks ahead of the validation. Developed in https://github.com/WordPress/wordpress-develop/pull/12482. Follow-up to r32579, r53881. Props josephscott, westonruter, mukesh27. See #65600. Fixes #65611. git-svn-id: https://develop.svn.wordpress.org/trunk@63006 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-xmlrpc-server.php | 40 ++- tests/phpunit/tests/xmlrpc/wp/uploadFile.php | 306 +++++++++++++++++++ 2 files changed, 340 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 7d64d3f46c019..1061dbd1831d2 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -6440,24 +6440,33 @@ public function mw_getCategories( $args ) { * @since 1.5.0 * * @param array $args { - * Method arguments. Note: arguments must be ordered as documented. + * Method arguments. Note: top-level arguments must be ordered as documented. * * @type int $0 Blog ID (unused). * @type string $1 Username. * @type string $2 Password. - * @type array $3 Data. + * @type array $3 { + * Data for the file to upload. + * + * @type string $name File name. Sanitized with sanitize_file_name(). + * @type string $type Optional. File MIME type, stored as the attachment's + * post MIME type. Default empty string. + * @type string $bits Optional. File contents. Default empty string. + * @type int $post_id Optional. ID of the post to attach the file to. + * Default 0. + * } * } * @return array|IXR_Error */ public function mw_newMediaObject( $args ) { + if ( ! $this->minimum_args( $args, 4 ) ) { + return $this->error; + } + $username = $this->escape( $args[1] ); $password = $this->escape( $args[2] ); $data = $args[3]; - $name = sanitize_file_name( $data['name'] ); - $type = $data['type']; - $bits = $data['bits']; - $user = $this->login( $username, $password ); if ( ! $user ) { return $this->error; @@ -6471,6 +6480,25 @@ public function mw_newMediaObject( $args ) { return $this->error; } + if ( + ! is_array( $data ) || + ! is_string( $data['name'] ?? null ) || + ! is_string( $data['type'] ?? '' ) || + ! is_string( $data['bits'] ?? '' ) + ) { + return new IXR_Error( 400, __( 'Invalid attachment data.' ) ); + } + + $name = sanitize_file_name( $data['name'] ); + + // A name consisting only of characters the sanitizer strips leaves nothing to write to. + if ( '' === $name ) { + return new IXR_Error( 400, __( 'Invalid attachment data.' ) ); + } + + $type = $data['type'] ?? ''; + $bits = $data['bits'] ?? ''; + if ( is_multisite() && upload_is_user_over_quota( false ) ) { $this->error = new IXR_Error( 401, diff --git a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php index 00cb601b28f3d..4dab3333fd8a0 100644 --- a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php +++ b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php @@ -34,4 +34,310 @@ public function test_valid_attachment() { $this->assertIsString( $result['url'] ); $this->assertIsString( $result['type'] ); } + + /** + * Tests that a non-array data argument returns an error instead of + * triggering a fatal error. + * + * The data argument (the fourth parameter) is expected to be a struct, + * which is passed to the method as an array. When it is any other type, + * the method must return an IXR_Error rather than attempting to access + * array offsets on a non-array value. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_invalid_attachment_data_should_return_error() { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'A non-array data argument should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Tests that an anonymous request with a non-array data argument returns + * the login error rather than triggering a fatal error. + * + * The reported fatal error was reached without credentials because the + * data struct was read before the login was attempted. The struct must + * only be read once the request is authenticated. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_anonymous_request_with_invalid_attachment_data_should_return_login_error() { + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'not-a-user', 'not-a-password', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'An anonymous request should return an IXR_Error.' ); + $this->assertSame( 403, $result->code, 'The error code should be the 403 returned for a failed login.' ); + } + + /** + * Tests that a user who cannot upload files is rejected before the data is + * read. + * + * The capability is checked ahead of the attachment data, so a user who is + * not allowed to upload is told that rather than being told the data is + * malformed. Sending unusable data must not change which error comes back. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + */ + public function test_incapable_user() { + $this->make_user_by_role( 'subscriber' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'subscriber', 'subscriber', 'not-a-struct' ) ); + $this->assertIXRError( $result, 'A user who cannot upload files should return an IXR_Error.' ); + $this->assertSame( 401, $result->code, 'The error code should be the 401 returned for a missing capability.' ); + } + + /** + * Tests that too few arguments return an error instead of emitting a PHP + * notice for the undefined arguments. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_insufficient_arguments + * + * @param list $args The arguments to pass to the method. + */ + public function test_insufficient_arguments_should_return_error( array $args ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( $args ); + $this->assertIXRError( $result, 'Insufficient arguments should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_insufficient_arguments(): array { + return array( + 'no arguments' => array( + 'args' => array(), + ), + 'only the blog ID' => array( + 'args' => array( 0 ), + ), + 'missing the data' => array( + 'args' => array( 0, 'editor', 'editor' ), + ), + ); + } + + /** + * Tests that a data struct without a usable file name returns an error + * instead of emitting a PHP notice for the undefined array key. + * + * A file name is required to write the upload, so the request cannot + * succeed. It must fail with an IXR_Error rather than by reading an + * undefined array offset. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_without_name + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_without_name_should_return_error( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A data argument without a name should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_without_name(): array { + return array( + 'empty struct' => array( + 'data' => array(), + ), + 'only type and bits' => array( + 'data' => array( + 'type' => 'image/jpeg', + 'bits' => 'contents', + ), + ), + 'non-string name' => array( + 'data' => array( + 'name' => array( 'a2-small.jpg' ), + 'type' => 'image/jpeg', + 'bits' => 'contents', + ), + ), + ); + } + + /** + * Tests that a file name left empty by sanitization returns the same error + * as an absent one. + * + * sanitize_file_name() strips special characters and then trims the + * remaining leading and trailing '.', '-' and '_' characters, so a name + * built only from those is reduced to an empty string. That leaves nothing + * to write, which is a malformed request rather than a server failure, so + * it must be reported as a 400 like any other unusable name instead of + * reaching wp_upload_bits() and surfacing as a 500. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_unusable_name + * + * @param string $name The file name to pass to the method. + */ + public function test_attachment_data_with_unusable_name_should_return_error( string $name ) { + $this->make_user_by_role( 'editor' ); + + $data = array( + 'name' => $name, + 'type' => 'image/jpeg', + 'bits' => 'contents', + ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A name left empty by sanitization should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_attachment_data_with_unusable_name(): array { + return array( + 'empty name' => array( + 'name' => '', + ), + 'only dots' => array( + 'name' => '...', + ), + 'only dashes' => array( + 'name' => '---', + ), + 'only underscores' => array( + 'name' => '___', + ), + 'only a space' => array( + 'name' => ' ', + ), + 'only special chars' => array( + 'name' => '///', + ), + 'only a question mark' => array( + 'name' => '?', + ), + ); + } + + /** + * Tests that a data struct with a non-string type or bits member returns an + * error instead of triggering a fatal error. + * + * A struct sent for either member arrives as an array. An array reaches + * fwrite() by way of wp_upload_bits(), which throws a TypeError, and it + * survives sanitize_mime_type() to reach the database as the attachment's + * post MIME type. Both members must be rejected before that point. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_invalid_members + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_with_invalid_members_should_return_error( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertIXRError( $result, 'A data argument with a non-string member should return an IXR_Error.' ); + $this->assertSame( 400, $result->code, 'The error code should be 400.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_with_invalid_members(): array { + return array( + 'non-string bits' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => 'image/jpeg', + 'bits' => array( 'contents' ), + ), + ), + 'non-string type' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => array( 'image/jpeg' ), + 'bits' => 'contents', + ), + ), + ); + } + + /** + * Tests that a data struct without the optional members is still accepted. + * + * Only the name is required. The type and bits members are tolerated when + * absent, and must not emit a PHP notice for the undefined array keys. + * + * @ticket 65611 + * + * @covers wp_xmlrpc_server::mw_newMediaObject + * + * @dataProvider data_attachment_data_with_optional_members_omitted + * + * @param array $data The data argument to pass to the method. + */ + public function test_attachment_data_with_optional_members_omitted_should_be_accepted( array $data ) { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) ); + $this->assertNotIXRError( $result ); + $this->assertIsString( $result['id'] ); + $this->assertStringMatchesFormat( '%d', $result['id'] ); + } + + /** + * Data provider. + * + * @return array}> + */ + public function data_attachment_data_with_optional_members_omitted(): array { + return array( + 'missing type' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'bits' => file_get_contents( DIR_TESTDATA . '/images/a2-small.jpg' ), + ), + ), + 'missing bits' => array( + 'data' => array( + 'name' => 'a2-small.jpg', + 'type' => 'image/jpeg', + ), + ), + ); + } } From 03b7c8a5b879d79c3322d7127e7533f43fef5ec9 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 4 Aug 2026 11:03:49 +0000 Subject: [PATCH 309/336] Media: Register the `wp-media-utils` style handle. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media editor modal rendered without its intended styles, because the stylesheet it relies on was never registered in core. Registering the handle makes the modal display as intended. Developed in https://github.com/WordPress/wordpress-develop/pull/12813 Props afercia, andrewserong, gulamdastgir04, mdridipu, ramonopoly, softglaze, wildworks. Fixes #65794. git-svn-id: https://develop.svn.wordpress.org/trunk@63007 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/script-loader.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index fd1f24a08968d..b5df9291ef354 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1781,9 +1781,11 @@ function wp_default_styles( $styles ) { 'wp-reusable-blocks', 'wp-patterns', 'wp-preferences', + 'wp-media-utils', ), 'format-library' => array(), 'list-reusable-blocks' => array( 'wp-components' ), + 'media-utils' => array( 'wp-components' ), 'reusable-blocks' => array( 'wp-components' ), 'patterns' => array( 'wp-components' ), 'preferences' => array( 'wp-components' ), @@ -1892,6 +1894,7 @@ function wp_default_styles( $styles ) { 'wp-editor', 'wp-format-library', 'wp-list-reusable-blocks', + 'wp-media-utils', 'wp-reusable-blocks', 'wp-patterns', 'wp-nux', From 815b17e10fc7cd2bebc691573239747f1f9047b2 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 4 Aug 2026 12:30:16 +0000 Subject: [PATCH 310/336] Networks and Sites: Correct capitalization in Upgrade Network admin notice. Follow-up to [https://mu.trac.wordpress.org/changeset/1968 mu:1968], [https://mu.trac.wordpress.org/changeset/2005 mu:2005], [13590]. Props bor0, realloc. Fixes #65792. git-svn-id: https://develop.svn.wordpress.org/trunk@63008 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/ms.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/ms.php b/src/wp-admin/includes/ms.php index 669c198fe9528..56e17113653d2 100644 --- a/src/wp-admin/includes/ms.php +++ b/src/wp-admin/includes/ms.php @@ -692,7 +692,7 @@ function site_admin_notice() { if ( (int) get_site_option( 'wpmu_upgrade_site' ) !== $wp_db_version ) { $upgrade_network_message = sprintf( /* translators: %s: URL to Upgrade Network screen. */ - __( 'Thank you for Updating! Please visit the Upgrade Network page to update all your sites.' ), + __( 'Thank you for updating! Please visit the Upgrade Network page to update all your sites.' ), esc_url( network_admin_url( 'upgrade.php' ) ) ); From c60dba3572080207fa4423bbf5b0a9429a1030b4 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Tue, 4 Aug 2026 17:28:48 +0000 Subject: [PATCH 311/336] Toolbar: Improve the focus style indication. - Updates the toolbar items styling by adding a more prominent focus indicator. - Adjusts label and icon coloring selectors (including mobile-specific focus states). - Refines the 'Howdy menu' dropdown layout and focus styles. - Tweaks the responsive menu toggle item sizing. Props afercia, joedolson, sabernhardt, khokansardar, jns141191, iamraju, sukhendu2002, shamimmoeen, ugyensupport. Fixes #65445. Fixes #65765. git-svn-id: https://develop.svn.wordpress.org/trunk@63009 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/admin-menu.css | 17 ++++-------- src/wp-admin/css/colors/_admin.scss | 32 ++++++++++++----------- src/wp-includes/css/admin-bar.css | 40 ++++++++++++----------------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/src/wp-admin/css/admin-menu.css b/src/wp-admin/css/admin-menu.css index 3747613282be1..aa6a8bc1414aa 100644 --- a/src/wp-admin/css/admin-menu.css +++ b/src/wp-admin/css/admin-menu.css @@ -800,12 +800,10 @@ li#wp-admin-bar-menu-toggle { display: block; padding: 0; overflow: hidden; - outline: none; text-decoration: none; - border: 1px solid transparent; background: none; - height: 44px; - margin-left: -1px; + height: 46px; + box-sizing: border-box; } .wp-responsive-open #wpadminbar #wp-admin-bar-menu-toggle a { @@ -816,22 +814,17 @@ li#wp-admin-bar-menu-toggle { display: block; } - #wpadminbar #wp-admin-bar-menu-toggle a:hover { - border: 1px solid transparent; - } - #wpadminbar #wp-admin-bar-menu-toggle .ab-icon:before { content: "\f228"; display: inline-block; float: left; - font: normal 40px/45px dashicons; + font: normal 40px/46px dashicons; vertical-align: middle; - outline: none; margin: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - height: 44px; - width: 50px; + height: 46px; + width: 52px; padding: 0; border: none; text-align: center; diff --git a/src/wp-admin/css/colors/_admin.scss b/src/wp-admin/css/colors/_admin.scss index 366f1b86b4f16..91d6a4b66bc8c 100644 --- a/src/wp-admin/css/colors/_admin.scss +++ b/src/wp-admin/css/colors/_admin.scss @@ -416,9 +416,11 @@ ul#adminmenu > li.current > a.current:after { background: variables.$menu-submenu-background; } -#wpadminbar:not(.mobile) > #wp-toolbar li:hover span.ab-label, -#wpadminbar:not(.mobile) > #wp-toolbar li.hover span.ab-label, -#wpadminbar:not(.mobile) > #wp-toolbar a:focus span.ab-label { +#wpadminbar > #wp-toolbar li:hover span.ab-label, +#wpadminbar > #wp-toolbar li.hover span.ab-label, +/* The adminbar menu may output either links or focusable div elements. */ +/* As such, we target the focus state without specifying the element type. */ +#wpadminbar > #wp-toolbar :focus span.ab-label { color: variables.$menu-submenu-focus-text; } @@ -449,7 +451,9 @@ ul#adminmenu > li.current > a.current:after { } #wpadminbar .quicklinks li .blavatar, -#wpadminbar .menupop .menupop > .ab-item:before { +#wpadminbar .menupop .menupop > .ab-item:before, +#wpadminbar.mobile .quicklinks .ab-icon:before, +#wpadminbar.mobile .quicklinks .ab-item:before { color: variables.$menu-icon; } @@ -474,21 +478,17 @@ ul#adminmenu > li.current > a.current:after { color: variables.$menu-submenu-focus-text; } +// Note that the icon of the site-name item is not wrapped within a span with class ab-icon like other items. #wpadminbar .quicklinks li a:hover .blavatar, #wpadminbar .quicklinks li a:focus .blavatar, #wpadminbar .quicklinks .ab-sub-wrapper .menupop.hover > a .blavatar, #wpadminbar .menupop .menupop > .ab-item:hover:before, #wpadminbar.mobile .quicklinks .hover .ab-icon:before, -#wpadminbar.mobile .quicklinks .hover .ab-item:before { +#wpadminbar.mobile .quicklinks .hover .ab-item:before, +#wpadminbar.mobile .quicklinks .ab-item:focus:before { color: variables.$menu-submenu-focus-text; } -#wpadminbar.mobile .quicklinks .ab-icon:before, -#wpadminbar.mobile .quicklinks .ab-item:before { - color: variables.$menu-icon; -} - - /* Admin Bar: search */ #wpadminbar #adminbarsearch:before { @@ -531,14 +531,16 @@ ul#adminmenu > li.current > a.current:after { color: variables.$menu-text; } -#wpadminbar #wp-admin-bar-user-info a:hover .display-name { - color: variables.$menu-submenu-focus-text; -} - #wpadminbar #wp-admin-bar-user-info .username { color: variables.$menu-submenu-text; } +#wpadminbar #wp-admin-bar-user-info a:hover .display-name, +#wpadminbar #wp-admin-bar-user-info a:focus .display-name, +#wpadminbar #wp-admin-bar-user-info a:hover .username, +#wpadminbar #wp-admin-bar-user-info a:focus .username { + color: variables.$menu-submenu-focus-text; +} /* Pointers */ diff --git a/src/wp-includes/css/admin-bar.css b/src/wp-includes/css/admin-bar.css index 77e196525657a..f50c67dd71689 100644 --- a/src/wp-includes/css/admin-bar.css +++ b/src/wp-includes/css/admin-bar.css @@ -77,10 +77,18 @@ html:lang(he-il) .rtl #wpadminbar * { box-shadow: none; } -#wpadminbar a:focus { - outline-offset: -1px; +#wpadminbar a:focus, +#wpadminbar .ab-item[tabindex="0"]:focus { + outline-offset: -2px; /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; + box-shadow: inset 0 -4px 0 0 currentColor; + transition: box-shadow 0.1s linear; +} + +#wpadminbar .ab-submenu a:focus, +#wpadminbar .ab-submenu .ab-item[tabindex="0"]:focus { + box-shadow: inset 4px 0 0 0 currentColor; } #wpadminbar { @@ -225,7 +233,9 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar:not(.mobile) > #wp-toolbar li:hover span.ab-label, #wpadminbar > #wp-toolbar li.hover span.ab-label, -#wpadminbar:not(.mobile) > #wp-toolbar a:focus span.ab-label { +/* The adminbar menu may output either links or focusable div elements. */ +/* As such, we target the focus state without specifying the element type. */ +#wpadminbar > #wp-toolbar :focus span.ab-label { color: #72aee6; } @@ -294,7 +304,8 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar li.hover .ab-icon:before, #wpadminbar li.hover .ab-item:before, #wpadminbar li:hover #adminbarsearch:before, -#wpadminbar li #adminbarsearch.adminbar-focused:before { +#wpadminbar li #adminbarsearch.adminbar-focused:before, +#wpadminbar.mobile .quicklinks .ab-item:focus:before { color: #72aee6; } @@ -379,11 +390,6 @@ html:lang(he-il) .rtl #wpadminbar * { float: right; } -#wpadminbar ul li:last-child, -#wpadminbar ul li:last-child .ab-item { - box-shadow: none; -} - /** * Recovery Mode */ @@ -452,7 +458,7 @@ html:lang(he-il) .rtl #wpadminbar * { #wp-admin-bar-user-info .avatar { position: absolute; left: -72px; - top: 4px; + top: 0; width: 64px; height: 64px; border-radius: 50%; @@ -466,7 +472,7 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar #wp-admin-bar-user-info span { background: none; padding: 0; - height: 18px; + line-height: 1.38461538; } #wpadminbar #wp-admin-bar-user-info .display-name, @@ -475,7 +481,6 @@ html:lang(he-il) .rtl #wpadminbar * { } #wpadminbar #wp-admin-bar-user-info .username { - color: #a7aaad; font-size: 11px; } @@ -911,7 +916,6 @@ html:lang(he-il) .rtl #wpadminbar * { overflow: hidden; width: 52px; padding: 0; - color: #a7aaad; /* @todo not needed? this text is hidden */ position: relative; } @@ -1031,16 +1035,6 @@ html:lang(he-il) .rtl #wpadminbar * { height: auto; font-size: 16px; line-height: 1.5; - color: #f0f0f1; - } - - #wpadminbar #wp-admin-bar-user-info a { - padding-top: 4px; - } - - #wpadminbar #wp-admin-bar-user-info .username { - line-height: 0.8 !important; - margin-bottom: -2px; } /* Show only default top level items */ From 71a2a03b81b698a7f4d19f6d390e6e27bcc85536 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Tue, 4 Aug 2026 18:36:23 +0000 Subject: [PATCH 312/336] Privacy: Fix checkbox alignment and highlight color. Adjust styling on privacy export and erasure tables for compatibility with the list table column changes in [62839]. Update the colors used to highlight confirmed or failed privacy requests following the admin color scheme changes in WordPress 7.0. Developed in https://github.com/WordPress/wordpress-develop/pull/12803 Props r1k0, joedolson, masteradhoc, shailu25. Fixes #65787. git-svn-id: https://develop.svn.wordpress.org/trunk@63010 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/forms.css | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index 3747fb5028484..bf14d3197401d 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -1493,7 +1493,7 @@ table.form-table td .updated p { border-left: 4px solid #fff; } -.privacy_requests tbody th { +.privacy_requests tbody td.check-column { border-left: 4px solid #fff; background: #fff; box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.1); @@ -1515,7 +1515,8 @@ table.form-table td .updated p { margin: 0 0 5px; } -.privacy_requests tbody td { +.privacy_requests tbody td:not(.check-column), +.privacy_requests tbody th { background: #fff; box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.1); } @@ -1529,16 +1530,14 @@ table.form-table td .updated p { white-space: normal; } -.privacy_requests .status-request-confirmed th, -.privacy_requests .status-request-confirmed td { +.privacy_requests tr.status-request-confirmed td.check-column { background-color: #fff; - border-left-color: #72aee6; + border-left-color: #3858e9; } -.privacy_requests .status-request-failed th, -.privacy_requests .status-request-failed td { +.privacy_requests tr.status-request-failed td.check-column { background-color: #f6f7f7; - border-left-color: #d63638; + border-left-color: #cc1818; } .privacy_requests .export_personal_data_failed a { @@ -1973,11 +1972,6 @@ table.form-table td .updated p { display: table-cell; } - .wp-list-table.privacy_requests.widefat th input, - .wp-list-table.privacy_requests.widefat thead td input { - margin-left: 5px; - } - .wp-privacy-request-form-field input[type="text"] { width: 100%; margin-bottom: 10px; From a105958a2052fe87f2374cf71178d92d4cde567a Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Tue, 4 Aug 2026 19:07:33 +0000 Subject: [PATCH 313/336] Privacy: Fix checkbox alignment in request form. The margins were set to `0` for all inputs in the request form, breaking the alignment for the checkbox. Limit margin resetting to inputs of type text. Change labeling from implicit to explicit labelling, to better support voice control users. Developed in https://github.com/WordPress/wordpress-develop/pull/11841 Props soyebsalar01, suryakantupadhyay, deepakprajapati, audrasjb, joedolson, adrianduffell, mukesh27, wildworks, masteradhoc. Fixes #65246. git-svn-id: https://develop.svn.wordpress.org/trunk@63011 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/forms.css | 2 +- src/wp-admin/erase-personal-data.php | 4 ++-- src/wp-admin/export-personal-data.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index bf14d3197401d..f7e6accfce0fd 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -1572,7 +1572,7 @@ table.form-table td .updated p { margin: 1.5em 0; } -.wp-privacy-request-form input { +.wp-privacy-request-form input[type="text"] { margin: 0; } diff --git a/src/wp-admin/erase-personal-data.php b/src/wp-admin/erase-personal-data.php index c96d80a9b5e06..a1472e7e7fd24 100644 --- a/src/wp-admin/erase-personal-data.php +++ b/src/wp-admin/erase-personal-data.php @@ -126,9 +126,9 @@ - + + diff --git a/src/wp-admin/export-personal-data.php b/src/wp-admin/export-personal-data.php index 64b9653c3c1ba..e9ccedc491c14 100644 --- a/src/wp-admin/export-personal-data.php +++ b/src/wp-admin/export-personal-data.php @@ -127,8 +127,8 @@ + From 439615d4b8b1736d421d512ca97d7a62d1d6ec87 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 4 Aug 2026 21:30:53 +0000 Subject: [PATCH 314/336] Comments: Notify users mentioned in a note. Introduce `wp_notify_note_mentions()` on `rest_insert_comment`, alongside the existing post author notification, which parses those IDs out of the saved note and emails each mentioned user in their own locale with a link back to the post editor. Recipients are limited to users who can `edit_comment` the note, matching `WP_REST_Comments_Controller::check_read_permission()`, so an email cannot carry note content to someone who cannot see the note in the editor. The note's own author is skipped, as is the post author, who `wp_new_comment_via_rest_notify_postauthor()` already notifies about every note. Only note creation notifies, and the existing `wp_notes_notify` option turns the whole path off. See related Gutenberg pull request: https://github.com/WordPress/gutenberg/pull/79606. Follow-up to [62832]. Props westonruter, mamaduka. Fixes #65639. git-svn-id: https://develop.svn.wordpress.org/trunk@63012 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/comment.php | 187 +++++++ src/wp-includes/default-filters.php | 1 + .../tests/comment/wpNotifyNoteMentions.php | 455 ++++++++++++++++++ 3 files changed, 643 insertions(+) create mode 100644 tests/phpunit/tests/comment/wpNotifyNoteMentions.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index ed7478f5ed104..96a26fcf558a1 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2556,6 +2556,193 @@ function wp_new_comment_via_rest_notify_postauthor( $comment ) { } } +/** + * Extracts the mentioned user IDs from note content. + * + * Mentions are stored as chips carrying the `wp-note-mention` class plus a + * `user-N` class token holding the mentioned user's ID: + * `@Name`. Only elements that + * carry both classes are treated as mentions. + * + * @since 7.1.0 + * + * @param string $content Note (comment) content, as stored. + * @return int[] Unique, positive mentioned user IDs. + * @phpstan-return list + */ +function wp_get_note_mentioned_user_ids( string $content ): array { + if ( ! str_contains( $content, 'wp-note-mention' ) ) { + return array(); + } + + $user_ids = array(); + $processor = new WP_HTML_Tag_Processor( $content ); + while ( + $processor->next_tag( + array( + 'tag_name' => 'SPAN', + 'class_name' => 'wp-note-mention', + ) + ) + ) { + foreach ( $processor->class_list() as $class_name ) { + if ( 1 === preg_match( '/^user-(\d+)$/', $class_name, $matches ) ) { + $user_id = (int) $matches[1]; + if ( $user_id > 0 ) { + $user_ids[] = $user_id; + } + break; + } + } + } + + return array_values( array_unique( $user_ids, SORT_NUMERIC ) ); +} + +/** + * Notifies mentioned users about a new note. + * + * Runs on {@see 'rest_insert_comment'} alongside the post author notification. + * The recipient set is the users mentioned in this note, minus the note's own + * author (a user is not notified about their own note) and the post author, + * who is already notified about every note by + * {@see wp_new_comment_via_rest_notify_postauthor()}. + * + * Only fires when a note is created, not when an existing one is edited, so + * correcting a note does not re-notify everyone who already received it. + * + * @since 7.1.0 + * + * @param WP_Comment|null $comment The note that was just inserted. (May only be null as an edge case.) + * @param mixed $request The REST request. Unused. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_note_mentions( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $creating || ! $comment ) { + return; + } + + if ( 'note' !== $comment->comment_type ) { + return; + } + + // Share the single user-facing notes notification preference. + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + + $author_id = (int) $comment->user_id; + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + $post_author_id = $post ? (int) $post->post_author : 0; + + /* + * The recipient set is bounded and small (one note's mentions), so emails + * are sent synchronously here. If notification volume ever warrants it, + * the right fix is to offload delivery to a background queue rather than + * throttle within the request. + */ + foreach ( $mentioned as $user_id ) { + // Never notify the author about their own note. + if ( $user_id === $author_id ) { + continue; + } + + // The post author is already notified of every note. + if ( $user_id === $post_author_id ) { + continue; + } + + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + /* + * Only notify users who can actually read the note. Notes are + * internal: WP_REST_Comments_Controller::check_read_permission() + * only exposes a note to its author or to users who can edit it, so + * the email audience is held to the same bar. A plain read_post + * check would leak note content to, for example, subscribers on a + * public post, who cannot see the note in the editor. + */ + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + wp_send_note_notification( $user, $comment, $post ); + } +} + +/** + * Sends a single note mention notification email. + * + * The email is composed in the recipient's locale, matching how other + * user-directed notifications are composed, and links to the post editor the + * same way the post author's note notification does. + * + * @since 7.1.0 + * + * @param WP_User $user The recipient. + * @param WP_Comment $comment The note that triggered the notification. + * @param WP_Post|null $post The post the note belongs to. + * @return bool Whether the email was accepted for delivery by {@see wp_mail()}. + */ +function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post ): bool { + $switched_locale = switch_to_user_locale( $user->ID ); + + /* + * The site title and the post title are escaped on the way into the database, + * and note content is stored as HTML. Both are reversed once here for the + * plain text arena of emails. Decoding a second time would go too far and + * resolve entities the author meant to be read literally. + */ + $blogname = wp_specialchars_decode( get_bloginfo( 'name', 'display' ), ENT_QUOTES ); + $post_title = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : ''; + $author_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' ); + $content = wp_specialchars_decode( wp_strip_all_tags( $comment->comment_content ) ); + + /* + * The rest of the message is composed for the recipient, and so is the editor + * link: get_edit_post_link() answers for whoever is current, which here is the + * note's author over REST and nobody at all under WP-Cron. + */ + $edit_link = ''; + if ( $post ) { + $previous_user_id = get_current_user_id(); + wp_set_current_user( $user->ID ); + $edit_link = (string) get_edit_post_link( $post->ID, 'url' ); + wp_set_current_user( $previous_user_id ); + } + + /* translators: 1: Note author's name, 2: Post title. */ + $message = sprintf( __( '%1$s mentioned you in a note on "%2$s".' ), $author_name, $post_title ); + /* translators: Note mention notification email subject. 1: Site title, 2: Post title. */ + $subject = sprintf( __( '[%1$s] You were mentioned in a note on "%2$s"' ), $blogname, $post_title ); + + $lines = array( $message, '' ); + if ( '' !== $content ) { + $lines[] = $content; + } + if ( $edit_link ) { + $lines[] = ''; + $lines[] = __( 'Edit This' ) . ': ' . $edit_link; + } + + // Declared explicitly so a filtered default cannot turn the message into HTML. + $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"'; + + $sent = wp_mail( $user->user_email, $subject, implode( "\n", $lines ), $headers ); + + if ( $switched_locale ) { + restore_previous_locale(); + } + + return $sent; +} + /** * Sets the status of a comment. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index ea6fee0dab3ad..12ca0045b98b4 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -536,6 +536,7 @@ add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); +add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); add_action( 'after_password_reset', 'wp_password_change_notification' ); add_action( 'register_new_user', 'wp_send_new_user_notifications' ); add_action( 'edit_user_created_user', 'wp_send_new_user_notifications', 10, 2 ); diff --git a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php new file mode 100644 index 0000000000000..f8e1eddc75293 --- /dev/null +++ b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php @@ -0,0 +1,455 @@ +, + * subject: string, + * message: string, + * headers: list, + * }> + */ + private array $sent = array(); + + /** + * Captured wp_mail() recipients for the current test. + * + * @var list + */ + private array $sent_to = array(); + + /** + * Sets up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$post_author = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$commenter = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$mentioned = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + + self::$post = $factory->post->create_and_get( array( 'post_author' => self::$post_author->ID ) ); + } + + public function set_up() { + parent::set_up(); + $this->sent = array(); + $this->sent_to = array(); + // Short-circuit wp_mail() and record what would have been sent. + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Records wp_mail() calls and short-circuits delivery. + * + * @param null $short_circuit Short-circuit value. + * @param array $atts wp_mail() arguments. + * @return bool Always true to indicate a "sent" message. + * + * @phpstan-param array{ + * to: non-falsy-string|list, + * subject: string, + * message: string, + * headers: string|list, + * ... + * } $atts + * @phpstan-return true + */ + public function capture_mail( $short_circuit, array $atts ): bool { + $to = (array) $atts['to']; + + $this->sent[] = array( + 'to' => $to, + 'subject' => $atts['subject'], + 'message' => $atts['message'], + 'headers' => (array) $atts['headers'], + ); + + foreach ( $to as $recipient ) { + $this->sent_to[] = $recipient; + } + + return true; + } + + /** + * Builds a note comment for the shared post. + * + * @param string $content Note content. + * @param int $user_id Author user ID. + * @param int $parent_id Parent note ID (0 for a top-level note). + * @return WP_Comment The inserted note. + */ + private function insert_note( string $content, int $user_id, int $parent_id = 0 ): WP_Comment { + $comment = self::factory()->comment->create_and_get( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_type' => 'note', + 'comment_content' => $content, + 'comment_parent' => $parent_id, + 'user_id' => $user_id, + ) + ); + assert( $comment instanceof WP_Comment ); + return $comment; + } + + /** + * Builds the stored markup for a mention of the given user. + * + * @param int $user_id User ID to mention. + * @param string $label Optional. The mention's visible text. + * @return string The mention chip markup. + */ + private function get_mention_markup( int $user_id, string $label = '@Mentioned' ): string { + return sprintf( '%s', $user_id, $label ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_parses_mentioned_user_ids() { + $content = '

        Hi @Jane and ' + . '@Bob.

        '; + + $this->assertSame( array( 5, 9 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_ignores_non_mentions_and_deduplicates() { + $content = '

        not a mention ' + . 'an anchor, not a chip ' + . '@Jane ' + . '@Jane again ' + . 'no user class

        '; + + $this->assertSame( array( 5 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_mentioned_user_is_emailed() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_contains_context_and_editor_link() { + /* + * The editor link comes from get_edit_post_link(), which is scoped to + * the current user; in the REST flow that is the note's author. + */ + wp_set_current_user( self::$commenter->ID ); + + $note = $this->insert_note( + '

        Please review ' . $this->get_mention_markup( self::$mentioned->ID, '@Reviewer' ) . '

        ', + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $email = $this->sent[0]; + + $this->assertStringContainsString( 'You were mentioned in a note', $email['subject'] ); + // The note text is included, stripped of markup. + $this->assertStringContainsString( 'Please review @Reviewer', $email['message'] ); + $this->assertStringNotContainsString( 'ID, 'url' ); + $this->assertIsString( $edit_link ); + $this->assertStringContainsString( + $edit_link, + $email['message'] + ); + } + + /** + * The editor link is composed for the recipient, not for whoever happens to + * be current, so it survives contexts with no logged-in user such as WP-Cron. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_editor_link_is_built_for_the_recipient() { + wp_set_current_user( 0 ); + + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + + // The switch is temporary; the caller's context is left as it was found. + $this->assertSame( 0, get_current_user_id() ); + + wp_set_current_user( self::$mentioned->ID ); + $edit_link = get_edit_post_link( self::$post->ID, 'url' ); + $this->assertIsString( $edit_link ); + $this->assertStringContainsString( $edit_link, $this->sent[0]['message'] ); + } + + /** + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_is_sent_as_plain_text() { + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Content-Type: text/plain', implode( "\n", $this->sent[0]['headers'] ) ); + } + + /** + * The post title is escaped on the way into the database, so it is decoded + * exactly once for the plain text email. Decoding twice resolves entities + * the author meant to be read literally. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_subject_decodes_the_post_title_once() { + // Stored form of the literal title "Tom & Jerry". + add_filter( + 'the_title', + static function () { + return 'Tom &amp; Jerry'; + } + ); + + $note = $this->insert_note( + $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Tom & Jerry', $this->sent[0]['subject'] ); + $this->assertStringNotContainsString( 'Tom & Jerry', $this->sent[0]['subject'] ); + } + + /** + * Note content is stored as HTML, so markup is stripped before entities are + * decoded. Decoding first would turn escaped text into tags and strip it. + * + * @ticket 65639 + * + * @covers ::wp_send_note_notification + */ + public function test_email_keeps_escaped_markup_in_the_note_text() { + $note = $this->insert_note( + '

        Use <code> tags ' . $this->get_mention_markup( self::$mentioned->ID ) . '

        ', + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertCount( 1, $this->sent ); + $this->assertStringContainsString( 'Use tags', $this->sent[0]['message'] ); + } + + /** + * @ticket 65639 + */ + public function test_author_is_not_notified_about_their_own_note() { + $note = $this->insert_note( + 'Note to ' . $this->get_mention_markup( self::$commenter->ID, '@Me' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertNotContains( self::$commenter->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_post_author_is_left_to_the_postauthor_notification() { + $note = $this->insert_note( + 'Hey ' . $this->get_mention_markup( self::$post_author->ID, '@Author' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + /* + * wp_new_comment_via_rest_notify_postauthor() notifies the post author + * of every note; the mention path must not also email them or they + * would receive a duplicate. + */ + $this->assertNotContains( self::$post_author->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_mentioned_user_without_note_access_is_not_emailed() { + $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $this->assertInstanceOf( WP_User::class, $subscriber ); + + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( $subscriber->ID, '@Subscriber' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + /* + * Notes are only readable by users who can edit them; a subscriber + * cannot, so emailing them would leak content they cannot see. + */ + $this->assertNotContains( $subscriber->user_email, $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_mentioning_a_nonexistent_user_sends_nothing() { + $note = $this->insert_note( + 'Ghost ' . $this->get_mention_markup( 999999, '@Ghost' ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_no_notifications_when_disabled() { + update_option( 'wp_notes_notify', 0 ); + + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_notify_note_mentions( $note ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * @ticket 65639 + */ + public function test_editing_a_note_does_not_renotify() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + // Simulate the update path of rest_insert_comment ( $creating false ). + wp_notify_note_mentions( $note, null, false ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * Creating a note through the REST endpoint must trigger the mention email. + * + * This exercises the `rest_insert_comment` wiring (hook name, priority and + * argument count), which the direct calls above bypass. + * + * @ticket 65639 + */ + public function test_rest_note_creation_triggers_mention_email() { + wp_set_current_user( self::$commenter->ID ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'content', 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * Updating a note through the REST endpoint must not re-notify. + * + * @ticket 65639 + */ + public function test_rest_note_update_does_not_renotify() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + wp_set_current_user( self::$commenter->ID ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note->comment_ID ); + $request->set_param( 'content', 'Edited ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotContains( self::$mentioned->user_email, $this->sent_to ); + } +} From 5bb4b68daf55e37f2582b10d33b74efa8601dbcd Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 4 Aug 2026 22:03:47 +0000 Subject: [PATCH 315/336] Media: Correct the HEIC upload error message. When a HEIC upload fails, the Media Library reported that "This image cannot be displayed in a web browser." That has not been accurate since [48288] introduced the string: Safari and other browsers render HEIC fine, and because the same message is sent to every browser it cannot describe what the visitor's own browser supports. The upload fails because the server's image editor cannot process the `image/heic` mime type, so the file is never converted to a web safe format - servers that do support HEIC convert it to JPEG, as of [58849]. Reword the message to name that cause and keep the existing suggestion to convert to JPEG. The `unsupported_image` string is only shown for queued HEIC files, so naming the format explicitly does not affect other uploads; WebP and AVIF continue to use `noneditable_image`. See related Gutenberg issue: https://github.com/WordPress/gutenberg/issues/81123. Follow-up to [48288]. Props khokansardar, annezazu. Fixes #65800. git-svn-id: https://develop.svn.wordpress.org/trunk@63013 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/script-loader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index b5df9291ef354..a364439f0abbb 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1038,7 +1038,7 @@ function wp_default_scripts( $scripts ) { 'deleted' => __( 'moved to the Trash.' ), /* translators: %s: File name. */ 'error_uploading' => __( '“%s” has failed to upload.' ), - 'unsupported_image' => __( 'This image cannot be displayed in a web browser. For best results convert it to JPEG before uploading.' ), + 'unsupported_image' => __( 'The server cannot process HEIC images. Convert it to JPEG before uploading.' ), 'noneditable_image' => __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), 'file_url_copied' => __( 'The file URL has been copied to your clipboard' ), ); From 7e5d241a2a5a68c739dbff722ea544ad0f665eb6 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 4 Aug 2026 22:56:49 +0000 Subject: [PATCH 316/336] Media: Skip server-side image scaling during client-side media processing. Disable the `big_image_size_threshold` filter alongside the existing client-side processing filters so the upload is stored untouched. The client's scaled sideload then keeps the plain `-scaled` name and records the untouched upload as `original_image`. Uploads that leave `generate_sub_sizes` enabled are unaffected. Props khokansardar, ianmjones. Fixes #65708. git-svn-id: https://develop.svn.wordpress.org/trunk@63014 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-rest-attachments-controller.php | 5 + .../rest-api/rest-attachments-controller.php | 169 ++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 6e06f1563c50c..78225e87e23da 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -459,6 +459,10 @@ public function create_item( $request ) { // Disable server-side EXIF rotation so the client can handle it. // This preserves the original orientation value in the metadata. add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); + // Disable server-side "big image" downscaling; the client supplies its + // own scaled version via the sideload endpoint. Scaling here would + // create a conflicting "-scaled" file and orphan the full-size upload. + add_filter( 'big_image_size_threshold', '__return_false', 100 ); } // Handle convert_format parameter. @@ -691,6 +695,7 @@ private function remove_client_side_media_processing_filters(): void { remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 ); remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + remove_filter( 'big_image_size_threshold', '__return_false', 100 ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 90899df850d47..72bb483d087be 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3875,6 +3875,175 @@ public function test_sideload_scaled_image() { $this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' ); } + /** + * When the client generates sub-sizes (generate_sub_sizes is false), the + * server must not perform its own "big image" downscaling on upload. + * + * Otherwise the server creates a `-scaled` file and records the upload as + * `original_image`. The client's subsequent scaled sideload then collides + * with that `-scaled` file and is renamed `-scaled-1`, the thumbnails + * inherit the numbered name, and the server-generated full-size file is + * left orphaned on disk. + * + * @ticket 65708 + * @requires function imagejpeg + */ + public function test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the image's dimensions so scaling would be + // triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + // Upload a large image with the client handling sub-size generation. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=33772.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + // The uploaded full-size image should be stored untouched: no + // server-side "-scaled" file and no original_image swap. + $original_file = get_attached_file( $attachment_id, true ); + $original_basename = wp_basename( $original_file ); + $original_name_stem = pathinfo( $original_basename, PATHINFO_FILENAME ); + $this->assertStringNotContainsString( '-scaled', $original_basename, 'The server should not create a -scaled file when the client generates sub-sizes.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayNotHasKey( 'original_image', $metadata, 'The server should not record an original_image when it does not scale the upload.' ); + + // The client's scaled sideload should now record the untouched upload as + // original_image and keep the -scaled name without a numeric suffix. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', "attachment; filename={$original_name_stem}-scaled.jpg" ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + $sub_size = $response->get_data(); + $this->assertSame( $original_basename, $sub_size['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( "{$original_name_stem}-scaled.jpg", wp_basename( $sub_size['file'] ), 'The scaled sideload should keep the -scaled name without a numeric collision suffix.' ); + } + + /** + * The complete client-side flow for an image over the "big image" threshold + * should write only files that the metadata tracks, so that deleting the + * attachment removes all of them. + * + * When the server scales the upload as well, its own full-size file is + * never referenced by the metadata and survives "Delete Permanently", the + * client's scaled sideload collides with the server's "-scaled" file and is + * stored as "-scaled-1", and the sub-sizes inherit the numbered name. + * + * @ticket 65708 + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_client_side_big_image_flow_leaves_no_orphaned_files() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Force the threshold below the uploaded image's dimensions so scaling + // would be triggered were it not suppressed for client-side processing. + add_filter( + 'big_image_size_threshold', + static function () { + return 1000; + } + ); + + $upload_dir = wp_upload_dir(); + $files_before = (array) glob( $upload_dir['path'] . '/*' ); + + // 1. Upload the full-size image; the client owns all the derivatives. + // 33772.jpg is 1920x1080, so it exceeds the threshold above. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' ); + + /* + * 2. Sideload a thumbnail, as the client does for each sub-size. The + * client names it after the file it uploaded, so a server-side + * rename of that file is what pushes this into a collision. + * test-image.jpg is 50x50, within the registered thumbnail maximum. + */ + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $thumbnail_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' ); + $this->assertSame( 'big-photo-150x150.jpg', wp_basename( $thumbnail_data['file'] ), 'The thumbnail should not inherit a numeric collision suffix.' ); + + // 3. Sideload the scaled full-size image. canola.jpg is 640x480, the + // size the client would have downscaled the upload to. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-scaled.jpg' ); + $request->set_param( 'image_size', 'scaled' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $scaled_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' ); + + // 4. Finalize, which writes the collected sub-size metadata in one pass. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $thumbnail_data, $scaled_data ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertSame( 'big-photo.jpg', $metadata['original_image'], 'The untouched upload should be recorded as original_image.' ); + $this->assertSame( 'big-photo-scaled.jpg', wp_basename( $metadata['file'] ), 'The client-supplied scaled image should become the attached file.' ); + $this->assertSame( 'big-photo-150x150.jpg', $metadata['sizes']['thumbnail']['file'], 'The thumbnail should keep its dimension-based name.' ); + + // Every file written for this attachment must be reachable from the + // metadata, otherwise it is orphaned on disk. + $written = array_map( 'wp_basename', array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ) ); + sort( $written ); + $this->assertSame( + array( 'big-photo-150x150.jpg', 'big-photo-scaled.jpg', 'big-photo.jpg' ), + $written, + 'The flow should write only the full-size upload, its scaled copy, and the sub-sizes.' + ); + + // Deleting the attachment should therefore clean all of them up. + wp_delete_attachment( $attachment_id, true ); + + $remaining = array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ); + $this->assertSame( array(), array_values( $remaining ), 'Deleting the attachment should leave no files behind.' ); + } + /** * Tests that sideloading scaled image requires authentication. * From 5f5d96bd4b0ad25b803dc507c08b5336fff926ed Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 4 Aug 2026 23:49:35 +0000 Subject: [PATCH 317/336] REST API: Bound the size of media sideloaded from a URL. Ensure upload limits are honored when fetching sideloaded image from URL. `WP_REST_Attachments_Controller::create_item_from_url()` only ran `check_upload_size()`, which returns early when `! is_multisite()`, so a single site had no ceiling at all on this path: `upload_max_filesize` and `post_max_size` bound a request body, not a fetch the server makes itself. Apply `wp_max_upload_size()` to the download, so a URL cannot bring in a file larger than the same site would accept as a direct upload, and pass that limit to the request as `limit_response_size` so an oversized file is not written to disk in full before being rejected. The multisite checks are unchanged and still run first, and no ceiling is applied when `wp_max_upload_size()` returns 0. Follow-up to [62659], [62841]. Props andrewserong, courane01. See #65517. git-svn-id: https://develop.svn.wordpress.org/trunk@63015 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-rest-attachments-controller.php | 42 +++++++++ .../rest-api/rest-attachments-controller.php | 92 +++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 78225e87e23da..9a7dabc7b3cf1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -630,12 +630,41 @@ protected function create_item_from_url( $request ) { ); } + /* + * Cap the download at the same size the site would accept as a direct + * upload. check_upload_size() only applies on multisite, so without a + * ceiling here a single site has no limit at all on this path: the + * `upload_max_filesize` and `post_max_size` directives bound a request + * body, not a fetch the server makes itself. + * + * When `wp_max_upload_size` returns 0, no ceiling is applied. + */ + $max_size = (int) wp_max_upload_size(); + /* * Download the remote file with WordPress's HTTP API, which validates * the host and blocks requests to private or local addresses. This is * the same primitive core's media_sideload_image() relies on. + * + * `limit_response_size` stops the transfer once the limit is passed, + * so an oversized remote file is never written to disk in full. One + * byte over the ceiling is enough to fail the size check below. */ + $limit_response_size = static function ( $args ) use ( $max_size ) { + $args['limit_response_size'] = $max_size + 1; + return $args; + }; + + if ( $max_size > 0 ) { + add_filter( 'http_request_args', $limit_response_size ); + } + $tmp_file = download_url( $url ); + + if ( $max_size > 0 ) { + remove_filter( 'http_request_args', $limit_response_size ); + } + if ( is_wp_error( $tmp_file ) ) { return $tmp_file; } @@ -653,6 +682,19 @@ protected function create_item_from_url( $request ) { return $size_check; } + if ( $max_size > 0 && wp_filesize( $tmp_file ) > $max_size ) { + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + + return new WP_Error( + 'rest_upload_file_too_big', + /* translators: %s: Maximum allowed file size in kilobytes. */ + sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), number_format( $max_size / KB_IN_BYTES ) ), + array( 'status' => 400 ) + ); + } + $attachment_id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $attachment_id ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 72bb483d087be..1efa090efb05c 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -5563,6 +5563,98 @@ public function test_create_item_from_url_exceeds_multisite_site_upload_space() $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); } + /** + * Verifies that the URL sideload path enforces the site's maximum upload + * size on single site as well as multisite. + * + * check_upload_size() returns early when ! is_multisite(), so before this + * check a single site had no ceiling at all on this path. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_exceeds_max_upload_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // The fixture the download is mocked with is comfortably larger than this. + add_filter( 'upload_size_limit', array( $this, 'filter_small_upload_size_limit' ), 20 ); + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/too-big.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + + /** + * Verifies that the download itself is bounded, so an oversized remote file + * is not written to disk in full before the size check rejects it. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_limits_the_download_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request_args = null; + + $capture_args = static function ( $response, $args, $url ) use ( &$request_args ) { + $request_args = $args; + + if ( ! empty( $args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array(), + 'cookies' => array(), + 'body' => '', + ); + }; + + add_filter( 'pre_http_request', $capture_args, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $capture_args, 10 ); + + $this->assertIsArray( $request_args, 'The download request should have been made.' ); + $this->assertSame( + (int) wp_max_upload_size() + 1, + $request_args['limit_response_size'], + 'The download should be capped one byte past the maximum upload size.' + ); + } + + /** + * Filters the maximum upload size down to a value smaller than the image + * fixture used to mock the download. + * + * @return int A deliberately small upload size limit, in bytes. + */ + public function filter_small_upload_size_limit() { + return 1024; + } + /** * Verifies that a URL with no usable path bails with a 400 before any * download is attempted, rather than handing an empty filename to the From 7f81cb2d0df5e3252ddc7c9aba65e23b6f965e82 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 01:33:06 +0000 Subject: [PATCH 318/336] Networks and Sites: Improve user autocomplete search term handling. In `wp_ajax_autocomplete_user()`, unslash and sanitize the `term` request parameter before it is passed to `get_users()`. Unslashing fixes searching for an email address containing an apostrophe (valid per `is_email()`), which could previously never match because `wp_magic_quotes()` added a slash which `wpdb::esc_like()` then escaped as a literal. Note that the raw term was already safely handled in the user query, since `WP_User_Query` passes the search term through `wpdb::prepare()`, so this is a hardening and correctness fix rather than a security fix. Additionally, a missing, non-string, or empty term now short-circuits with a `0` response instead of returning an empty array, avoiding a PHP warning and needless user queries. Asterisks are also trimmed from the term given that wildcards are appended to it; a term consisting only of asterisks previously resulted in an empty search which matched all users on the network. Also introduce the `Tests_Ajax_wpAjaxAutocompleteUser` test class covering the Ajax action's search behavior, input handling, and capability checks. Developed in https://github.com/WordPress/wordpress-develop/pull/11530. Follow-up to r19897, r20279. Props rajeshcp, wildworks, westonruter, liaison, gaurangsondagar, vgnavada, saadtajik. Fixes #65051. git-svn-id: https://develop.svn.wordpress.org/trunk@63016 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/ajax-actions.php | 20 +- .../tests/ajax/wpAjaxAutocompleteUser.php | 412 ++++++++++++++++++ 2 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 3cda30f0d523f..c51751940a976 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -285,6 +285,10 @@ function wp_ajax_oembed_cache() { * Handles user autocomplete via AJAX. * * @since 3.4.0 + * @since 7.1.0 The search term is now sanitized, and a missing, non-string, + * or empty term results in a `0` response instead of an empty array. + * + * @return never */ function wp_ajax_autocomplete_user() { if ( ! is_multisite() || ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) { @@ -298,6 +302,20 @@ function wp_ajax_autocomplete_user() { $return = array(); + // Obtain the search term, and short-circuit missing/invalid search term. + if ( ! isset( $_REQUEST['term'] ) || ! is_string( $_REQUEST['term'] ) ) { + wp_die( 0 ); + } + /* + * Asterisks are trimmed since wildcards are appended below. Without this, a + * term consisting only of asterisks would result in an empty search that + * matches all users. + */ + $term = trim( sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ), '*' ); + if ( '' === $term ) { + wp_die( 0 ); + } + /* * Check the type of request. * Current allowed values are `add` and `search`. @@ -342,7 +360,7 @@ function wp_ajax_autocomplete_user() { $users = get_users( array( 'blog_id' => false, - 'search' => '*' . $_REQUEST['term'] . '*', + 'search' => '*' . $term . '*', 'include' => $include_blog_users, 'exclude' => $exclude_blog_users, 'search_columns' => array( 'user_login', 'user_nicename', 'user_email' ), diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php new file mode 100644 index 0000000000000..3d1eecf2f0fa3 --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -0,0 +1,412 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$site_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$target_user_id = $factory->user->create( + array( + 'role' => 'subscriber', + 'user_login' => 'autocompleteuser', + 'user_email' => 'autocompleteuser+bat\'leth@klingon.example.org', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Runs the Ajax handler and returns the response passed to wp_die(). + * + * The handler never echoes anything, so the response is only available + * through the exception thrown by the die handler. + * + * @return string The raw response. + */ + protected function handle_autocomplete_user(): string { + try { + $this->_handleAjax( 'autocomplete-user' ); + } catch ( WPAjaxDieStopException $e ) { + return $e->getMessage(); + } + + $this->fail( 'wp_ajax_autocomplete_user() did not stop execution.' ); + } + + /** + * Tests that users of the current site are returned when searching them. + * + * @ticket 65051 + */ + public function test_should_return_users_matching_the_search_term() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser', $result['value'], 'The user login should be returned as the value.' ); + $this->assertIsString( $result['label'] ); + $this->assertStringContainsString( 'autocompleteuser+bat\'leth@klingon.example.org', $result['label'], 'The label should contain the email address.' ); + } + + /** + * Tests that the email address is returned when it is the requested field. + * + * @ticket 65051 + */ + public function test_should_return_the_email_address_as_the_value_when_requested() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that users of the current site are excluded when adding a user to it. + * + * @ticket 65051 + */ + public function test_should_exclude_users_of_the_current_site_when_adding() { + wp_set_current_user( self::$super_admin_id ); + + // The default autocomplete type is 'add', which excludes existing users of the site. + $_GET = wp_slash( + array( + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertSame( array(), $response, 'A user of the current site should not be suggested.' ); + } + + /** + * Tests that HTML tags are removed from the search term. + * + * @ticket 65051 + * + * @dataProvider data_terms_containing_tags + * + * @param string $term Term containing HTML tags. + */ + public function test_should_strip_tags_from_the_search_term( string $term ) { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => $term, + ) + ); + + $search = null; + add_action( + 'pre_get_users', + static function ( WP_User_Query $query ) use ( &$search ) { + $search = $query->get( 'search' ); + } + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + + $this->assertSame( '*autocompleteuser*', $search, 'The search term should be sanitized before it is passed to get_users().' ); + $this->assertCount( 1, $response, 'The sanitized term should still match the user.' ); + } + + /** + * Data provider. + * + * Note that `wp_strip_all_tags()` removes script and style elements along + * with their contents, while for other tags only the tags themselves are + * removed. + * + * @return array + */ + public static function data_terms_containing_tags(): array { + return array( + 'script element after the term' => array( 'autocompleteuser' ), + 'tags wrapping the term' => array( 'autocompleteuser' ), + ); + } + + /** + * Tests that searching for an email address with apostrophes is successful. + * + * @ticket 65051 + */ + public function test_search_email_address_with_apostrophe() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser+bat\'leth@klingon.example.org', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that a missing search term does not return results. + * + * @ticket 65051 + */ + public function test_missing_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that an empty search term does not return results. + * + * @ticket 65051 + * + * @dataProvider data_empty_terms + * + * @param string $term Empty or whitespace-only term. + */ + public function test_empty_term_does_not_return_results( string $term ) { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => $term, + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_empty_terms(): array { + return array( + 'empty string' => array( '' ), + 'whitespace only' => array( ' ' ), + ); + } + + /** + * Tests that a non-string search term does not return results. + * + * @ticket 65051 + */ + public function test_non_string_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => array( 'autocompleteuser' ), + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that a term consisting only of asterisks does not match all users. + * + * @ticket 65051 + */ + public function test_asterisk_only_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '**', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that a term wrapped in asterisks still matches. + * + * @ticket 65051 + */ + public function test_asterisk_wrapped_term_returns_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '*autocompleteuser*', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + + /** + * Tests that users without the 'promote_users' capability are denied. + * + * @ticket 65051 + */ + public function test_should_deny_users_without_the_promote_users_capability() { + wp_set_current_user( self::$subscriber_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are denied unless the filter allows them. + * + * @ticket 65051 + */ + public function test_should_deny_site_administrators_by_default() { + wp_set_current_user( self::$site_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are allowed by the + * 'autocomplete_users_for_site_admins' filter. + * + * @ticket 65051 + */ + public function test_should_allow_site_administrators_when_filtered() { + wp_set_current_user( self::$site_admin_id ); + + add_filter( 'autocomplete_users_for_site_admins', '__return_true' ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + + /** + * Tests that no autocompletion happens on large networks. + * + * @ticket 65051 + */ + public function test_should_deny_the_request_on_a_large_network() { + wp_set_current_user( self::$super_admin_id ); + + add_filter( 'wp_is_large_network', '__return_true' ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } +} From 3a20e599baf75e8d568f0bfd029be4f8ef4094a4 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 5 Aug 2026 06:16:54 +0000 Subject: [PATCH 319/336] Media: Normalize the order property in media models. The Media Library grid view renders attachments in the reverse of the order the server returned whenever the `order` query var is present but not uppercase, most commonly after sorting in list view and then clicking the grid view toggle, which carries `order=desc` over in the URL. `WP_Query` normalizes and defaults `order` server side, but the media models compare against the literal strings `'ASC'` and `'DESC'`, so a lowercase or invalid value flips the display. `wp.media.model.Query.get()` already normalized `order`, but `wp.media.model.Attachments.initialize()` did not, so a `Query` and the plain `Attachments` collection mirroring it could disagree about the sort direction. Normalizing at initialization instead gives every attachment collection a consistent `order` regardless of how it was constructed. Props trivedikavit, sabernhardt, mukesh27, shailu25, soyebsalar01, ozgursar, darshitrajyaguru97. Fixes #64467. git-svn-id: https://develop.svn.wordpress.org/trunk@63017 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/media/models/attachments.js | 16 +- src/js/media/models/query.js | 6 - tests/qunit/index.html | 1 + .../wp-includes/js/media/test-media-models.js | 254 ++++++++++++++++++ 4 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 tests/qunit/wp-includes/js/media/test-media-models.js diff --git a/src/js/media/models/attachments.js b/src/js/media/models/attachments.js index 1683494388283..fb31ba09ab9d6 100644 --- a/src/js/media/models/attachments.js +++ b/src/js/media/models/attachments.js @@ -32,6 +32,8 @@ var Attachments = Backbone.Collection.extend(/** @lends wp.media.model.Attachmen * @param {Object} [options={}] */ initialize: function( models, options ) { + var normalizedOrder; + options = options || {}; this.props = new Backbone.Model(); @@ -44,7 +46,19 @@ var Attachments = Backbone.Collection.extend(/** @lends wp.media.model.Attachmen this.props.on( 'change:orderby', this._changeOrderby, this ); this.props.on( 'change:query', this._changeQuery, this ); - this.props.set( _.defaults( options.props || {} ) ); + options.props = options.props || {}; + + /* + * Normalize the order, if one is set. `Attachments.comparator()` and the + * `order` filter in `wp.media.model.Query` both test for the literal + * strings 'ASC' and 'DESC', so anything else has to fall back to 'DESC'. + */ + if ( ! _.isUndefined( options.props.order ) && ! _.isNull( options.props.order ) ) { + normalizedOrder = String( options.props.order ).toUpperCase(); + options.props.order = ( 'ASC' === normalizedOrder || 'DESC' === normalizedOrder ) ? normalizedOrder : 'DESC'; + } + + this.props.set( options.props ); if ( options.observe ) { this.observe( options.observe ); diff --git a/src/js/media/models/query.js b/src/js/media/models/query.js index b3f62018f5cd4..3c47215c39833 100644 --- a/src/js/media/models/query.js +++ b/src/js/media/models/query.js @@ -251,12 +251,6 @@ Query = Attachments.extend(/** @lends wp.media.model.Query.prototype */{ // Fill default args. _.defaults( props, defaults ); - // Normalize the order. - props.order = props.order.toUpperCase(); - if ( 'DESC' !== props.order && 'ASC' !== props.order ) { - props.order = defaults.order.toUpperCase(); - } - // Ensure we have a valid orderby value. if ( ! _.contains( orderby.allowed, props.orderby ) ) { props.orderby = defaults.orderby; diff --git a/tests/qunit/index.html b/tests/qunit/index.html index a6b6177014586..d0e81acedb502 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -152,6 +152,7 @@ + diff --git a/tests/qunit/wp-includes/js/media/test-media-models.js b/tests/qunit/wp-includes/js/media/test-media-models.js new file mode 100644 index 0000000000000..b6d5563f584b6 --- /dev/null +++ b/tests/qunit/wp-includes/js/media/test-media-models.js @@ -0,0 +1,254 @@ +/* globals wp */ +/* jshint qunit: true */ +/* eslint-env qunit */ +/* eslint-disable no-magic-numbers */ + +( function() { + 'use strict'; + + QUnit.module( 'Media Models - Order Normalization' ); + + // Test valid uppercase values + QUnit.test( 'Attachments should accept uppercase "ASC" order', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'ASC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should remain ASC when passed as uppercase' ); + }); + + QUnit.test( 'Attachments should accept uppercase "DESC" order', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'DESC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should remain DESC when passed as uppercase' ); + }); + + // Test lowercase normalization + QUnit.test( 'Attachments should normalize lowercase "asc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'asc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should be converted from lowercase asc to uppercase ASC' ); + }); + + QUnit.test( 'Attachments should normalize lowercase "desc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'desc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should be converted from lowercase desc to uppercase DESC' ); + }); + + // Test mixed case normalization + QUnit.test( 'Attachments should normalize mixed case "AsC" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'AsC' + } + }); + + assert.strictEqual( collection.props.get('order'), 'ASC', + 'Order should be converted from mixed case AsC to uppercase ASC' ); + }); + + QUnit.test( 'Attachments should normalize mixed case "DeSc" to uppercase', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'DeSc' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order should be converted from mixed case DeSc to uppercase DESC' ); + }); + + // Test invalid string values + QUnit.test( 'Attachments should default invalid string order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'invalid' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Invalid string order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default empty string order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: '' + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Empty string order should default to DESC' ); + }); + + /* + * An unset order is left alone so the existing 'DESC' fallbacks in + * Attachments.comparator() still apply. Any value that *is* set gets + * normalized, otherwise a truthy non-string would sort ascending. + */ + QUnit.test( 'Attachments should leave a null order value unset', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: null + } + }); + + assert.strictEqual( collection.props.get('order'), null, + 'Null order should remain null' ); + }); + + QUnit.test( 'Attachments should leave an undefined order value unset', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: undefined + } + }); + + assert.strictEqual( collection.props.get('order'), undefined, + 'Undefined order should remain undefined' ); + }); + + QUnit.test( 'Attachments should default a numeric order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 123 + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Numeric order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default a boolean true order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: true + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Boolean true order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default a boolean false order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: false + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Boolean false order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default an object order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: { value: 'ASC' } + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Object order should default to DESC' ); + }); + + QUnit.test( 'Attachments should default an array order to "DESC"', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: ['ASC', 'DESC'] + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Array order should default to DESC' ); + }); + + // Test when no order property is provided + QUnit.test( 'Attachments should work when no order property is provided', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + orderby: 'date' + } + }); + + assert.strictEqual( collection.props.get('order'), undefined, + 'Order should be undefined when not provided' ); + }); + + /* + * Query no longer normalizes the order itself, it relies on inheriting the + * normalization above. Note these pass `args` rather than `props.query`: + * setting `query` would kick off a server request via `_requery()`. + */ + QUnit.test( 'Query should inherit order normalization from Attachments', function( assert ) { + var query = new wp.media.model.Query( [], { + props: { + order: 'asc' + }, + args: {} + }); + + assert.strictEqual( query.props.get('order'), 'ASC', + 'Query model should normalize order through inheritance from Attachments' ); + assert.ok( query instanceof wp.media.model.Attachments, + 'Query should be instance of Attachments' ); + }); + + QUnit.test( 'Query should default invalid order to "DESC"', function( assert ) { + var query = new wp.media.model.Query( [], { + props: { + order: 'random' + }, + args: {} + }); + + assert.strictEqual( query.props.get('order'), 'DESC', + 'Query model should default invalid order to DESC' ); + }); + + // Test whitespace handling + QUnit.test( 'Attachments should handle order with whitespace', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: ' asc ' + } + }); + + assert.notStrictEqual( collection.props.get('order'), 'ASC', + 'Order with whitespace should not match ASC exactly' ); + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order with whitespace should default to DESC as it does not match ASC/DESC after toUpperCase' ); + }); + + // Test unicode characters + QUnit.test( 'Attachments should handle order with unicode characters', function( assert ) { + var collection = new wp.media.model.Attachments( [], { + props: { + order: 'asc\u200B' // Zero-width space + } + }); + + assert.strictEqual( collection.props.get('order'), 'DESC', + 'Order with unicode characters should default to DESC' ); + }); + +})(); From 6904e896b870e1ddd4e706edcd990b794977fec1 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 5 Aug 2026 06:40:32 +0000 Subject: [PATCH 320/336] REST API: Always register the media creation arguments. The `url`, `generate_sub_sizes`, and `convert_format` arguments for `POST /wp/v2/media` were only registered when client side media processing is enabled, but `create_item()` and `create_item_permissions_check()` honored all three either way. Since an unregistered argument skips the validation and sanitization its registration carries, an unsafe sideload `url` failed with a bare `http_request_failed` instead of a 400. Gating registration also made the schema depend on request context rather than site configuration, since `wp_is_client_side_media_processing_enabled()` is derived from `is_ssl()` and the host, so the same site could advertise different arguments depending on how it was reached. All three arguments are now registered unconditionally. None of them require the feature: sideloading from a URL works around a cross-origin fetch the browser cannot make, and skipping sub-size generation or format conversion is something the server can do on its own. One condition is kept: `generate_sub_sizes` of `false` no longer relaxes the unsupported image type check in `create_item_permissions_check()` unless client side media processing is enabled, since that check exists because the server cannot process the image and should only be relaxed when the client can. Behavior with client side media processing enabled is unchanged. Follow-up to [62659], [62841]. Props andrewserong, jeremyfelt. Fixes #65808. See #65517. git-svn-id: https://develop.svn.wordpress.org/trunk@63018 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-rest-attachments-controller.php | 100 +++++----- .../rest-api/rest-attachments-controller.php | 179 ++++++++++++++++++ 2 files changed, 234 insertions(+), 45 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 9a7dabc7b3cf1..f336f321a9ea2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -237,50 +237,54 @@ public function register_routes() { public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { $args = parent::get_endpoint_args_for_item_schema( $method ); - if ( WP_REST_Server::CREATABLE === $method && wp_is_client_side_media_processing_enabled() ) { - $args['generate_sub_sizes'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to generate image sub sizes.' ), - ); - $args['convert_format'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to convert image formats.' ), - ); - $args['url'] = array( - 'type' => 'string', - 'format' => 'uri', - 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), - 'sanitize_callback' => 'sanitize_url', - 'validate_callback' => static function ( $url, $request, $param ) { - /* - * A custom validate_callback replaces the default - * rest_validate_request_arg(), so re-apply it first to keep - * the schema checks (string type, uri format) enforced. - */ - $valid = rest_validate_request_arg( $url, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } + if ( WP_REST_Server::CREATABLE !== $method ) { + return $args; + } - /* - * Reject URLs that are not safe to request server-side. wp_http_validate_url() - * enforces an HTTP(S) scheme and blocks private, local, and otherwise - * disallowed hosts, guarding the sideload against SSRF. - */ - if ( false === wp_http_validate_url( $url ) ) { - return new WP_Error( - 'rest_invalid_url', - __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), - array( 'status' => 400 ) - ); - } + $args['generate_sub_sizes'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to generate image sub sizes.' ), + ); - return true; - }, - ); - } + $args['convert_format'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.' ), + ); + + $args['url'] = array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), + 'sanitize_callback' => 'sanitize_url', + 'validate_callback' => static function ( $url, $request, $param ) { + /* + * A custom validate_callback replaces the default + * rest_validate_request_arg(), so re-apply it first to keep + * the schema checks (string type, uri format) enforced. + */ + $valid = rest_validate_request_arg( $url, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + /* + * Reject URLs that are not safe to request server-side. wp_http_validate_url() + * enforces an HTTP(S) scheme and blocks private, local, and otherwise + * disallowed hosts, guarding the sideload against SSRF. + */ + if ( false === wp_http_validate_url( $url ) ) { + return new WP_Error( + 'rest_invalid_url', + __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), + array( 'status' => 400 ) + ); + } + + return true; + }, + ); return $args; } @@ -381,9 +385,15 @@ public function create_item_permissions_check( $request ) { */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null ); - // When the client handles image processing (generate_sub_sizes is false), - // skip the server-side image editor support check. - if ( false === $request['generate_sub_sizes'] ) { + /* + * When the client handles image processing (generate_sub_sizes is false), + * skip the server-side image editor support check. This check exists + * because the server cannot process the image, so it is only relaxed when + * client side media processing is enabled and something else can. Asking + * to skip sub sizes on a site without it does not make an unsupported + * image type any more usable. + */ + if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) { $prevent_unsupported_uploads = false; } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 1efa090efb05c..7f4dcd06c2f71 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -207,6 +207,18 @@ private function enable_client_side_media_processing(): void { do_action( 'rest_api_init', $wp_rest_server ); } + /** + * Turns client-side media processing off and rebuilds the REST server so the + * routes are registered with the feature disabled. + */ + private function disable_client_side_media_processing(): void { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/media', $routes ); @@ -3412,9 +3424,16 @@ public function test_upload_unsupported_image_type_with_filter() { * Tests the permissions check directly with file params set, since the core * check uses get_file_params() which is only populated for multipart uploads. * + * The check is only relaxed when client-side media processing is enabled, + * since that is what makes the client able to handle the image, so the + * feature is enabled here. + * * @ticket 64836 + * @ticket 65517 */ public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); add_filter( 'wp_image_editors', '__return_empty_array' ); @@ -5830,6 +5849,166 @@ public function test_url_registered_as_creatable_arg() { $this->assertSame( 'uri', $creatable['args']['url']['format'] ); } + /** + * Verifies that the media creation arguments are registered even when + * client-side media processing is disabled. + * + * The feature is determined per request, from the scheme and host, so gating + * the schema on it would advertise different arguments for the same site + * depending on how it was reached. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_creatable_args_registered_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $creatable = null; + foreach ( $routes['/wp/v2/media'] as $route ) { + if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) { + $creatable = $route; + break; + } + } + + $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' ); + $this->assertArrayHasKey( 'url', $creatable['args'] ); + $this->assertArrayHasKey( 'generate_sub_sizes', $creatable['args'] ); + $this->assertArrayHasKey( 'convert_format', $creatable['args'] ); + } + + /** + * Verifies that sideloading an external image works when client-side media + * processing is disabled. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url ); + } + + /** + * Verifies that the `url` argument's validation runs when client-side media + * processing is disabled, so an unsafe URL is rejected with a 400 rather than + * reaching the download. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_arg_rejects_unsafe_urls_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'http://127.0.0.1/private.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that `generate_sub_sizes` is honored when client-side media + * processing is disabled. + * + * Skipping sub-size generation is a request the server can carry out on its + * own, so it does not depend on the feature. Sub-sizes can still be added + * later with wp_update_image_subsizes(). + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_generate_sub_sizes_honored_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertEmpty( + $metadata['sizes'] ?? array(), + 'Sub-sizes should not be generated when generate_sub_sizes is false.' + ); + } + + /** + * Verifies that `generate_sub_sizes` does not relax the unsupported image + * type check when client-side media processing is disabled. + * + * That check exists because the server cannot process the image, so it should + * only be relaxed when the client can process it instead. Otherwise the + * upload is stored unprocessable. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_permissions_check + */ + public function test_unsupported_image_type_still_checked_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + $request->set_param( 'generate_sub_sizes', false ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); + } + /** * Verifies that the `url` argument rejects values that are not safe to * request server-side, guarding the sideload against SSRF. From e9e0be1c75b3bb2ce472d4c7891f3d7906f227d3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 07:16:57 +0000 Subject: [PATCH 321/336] Build/Test Tools: Raise the PHPStan rule level to 1. Level 1 adds detection of possibly undefined variables, and of unknown magic methods and properties on classes with `__call` and `__get`. The 494 errors this surfaces in existing code are recorded in baselines rather than being fixed here, so that new code is held to level 1 straight away while the existing reports are worked through separately. No files under `src` are changed. The `tests/phpstan/baseline.php` file is replaced by one baseline per error identifier under `tests/phpstan/baselines`, so that the remaining work on each kind of error is visible as a single file that should shrink to nothing and then be deleted. Every entry is scoped to the file which the error occurs in and carries an exact occurrence count, so that a new occurrence of an already baselined error is reported rather than absorbed. The consequence is that fixing a baselined error means regenerating its baseline in the same change, because the count no longer matches. PHPStan's own `--generate-baseline` captures every error a run reports, with no way to restrict it to one identifier, so `tests/phpstan/generate-baselines.php` is added to write the files instead, exposed as `composer phpstan:baselines` and as `npm run typecheck:php:baselines`. A run also deletes any baseline whose identifier no longer reports anything, and rewrites the list of baselines in `phpstan.neon.dist`. The `ignoreErrors` in that file now has a comment explaining how it is distinct from a baseline: an entry there is a decision that the code is right as written, whereas a baseline entry is work still to be done. The constants that `add_theme_support()` defines are declared in the configuration so that the errors around them are resolved rather than recorded, and `tests/phpstan/README.md` is updated throughout. Three problems in the static analysis GHA workflow are fixed as well. Fixing a baselined error makes PHPStan report an unmatched ignore, which surfaced only as an annotation reading like a complaint about a correct fix; the job now detects any `ignore.*` report and fails with an explanation of what to run. An analysis that did not finish passed as a green run, because the status of the pipeline was that of `cs2pr` rather than of PHPStan; that status is now recovered and a run that did not finish fails. The path filter deciding whether the workflow runs named only the old baseline file, so a pull request that merely regenerated the baselines would not have run the analysis that checks them. Developed in https://github.com/WordPress/wordpress-develop/pull/11151. Follow-up to r61699. Props westonruter, sabernhardt, apermo, johnjamesjacoby, adamsilverstein, justlevine. See #61175. Fixes #64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63019 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/phpstan-static-analysis.yml | 2 +- .../reusable-phpstan-static-analysis-v1.yml | 95 +- composer.json | 1 + package.json | 3 +- phpstan.neon.dist | 27 +- tests/phpstan/README.md | 68 +- tests/phpstan/base.neon | 7 + tests/phpstan/baseline.php | 3 - tests/phpstan/baselines/empty.variable.neon | 65 ++ tests/phpstan/baselines/isset.variable.neon | 50 + .../phpstan/baselines/variable.undefined.neon | 995 ++++++++++++++++++ tests/phpstan/bootstrap.php | 9 + tests/phpstan/generate-baselines.php | 662 ++++++++++++ 13 files changed, 1970 insertions(+), 17 deletions(-) delete mode 100644 tests/phpstan/baseline.php create mode 100644 tests/phpstan/baselines/empty.variable.neon create mode 100644 tests/phpstan/baselines/isset.variable.neon create mode 100644 tests/phpstan/baselines/variable.undefined.neon create mode 100644 tests/phpstan/generate-baselines.php diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml index 62061a83a2688..7d7043ac0c1ec 100644 --- a/.github/workflows/phpstan-static-analysis.yml +++ b/.github/workflows/phpstan-static-analysis.yml @@ -18,7 +18,7 @@ on: # These files configure PHPStan. Changes could affect the outcome. - 'phpstan.neon.dist' - 'tests/phpstan/base.neon' - - 'tests/phpstan/baseline.php' + - 'tests/phpstan/baselines/**' # Confirm any changes to relevant workflow files. - '.github/workflows/phpstan-static-analysis.yml' - '.github/workflows/reusable-phpstan-static-analysis-v1.yml' diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index a69b3b46fdea4..26a14ba8d890f 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -32,6 +32,7 @@ jobs: # - Builds WordPress. # - Configures caching for PHPStan static analysis scans. # - Runs PHPStan static analysis (with Pull Request annotations). + # - Checks whether the baselines need regenerating. # - Saves the PHPStan result cache. # - Ensures version-controlled files are not modified or deleted. phpstan: @@ -93,7 +94,99 @@ jobs: - name: Run PHP static analysis tests id: phpstan - run: composer run phpstan -- -vvv --error-format=checkstyle | cs2pr --errors-as-warnings --graceful-warnings + run: | + # The report is written to a file as well as piped to cs2pr, so that the step below + # can look at it. + # + # cs2pr exits successfully so that reported errors annotate the pull request without + # failing the run. A pipeline reports only the status of its last command, so that + # also discards the status of the analysis itself. Recover it from PIPESTATUS. + composer run phpstan -- -vvv --error-format=checkstyle | tee "${RUNNER_TEMP}/phpstan-report.xml" | cs2pr --errors-as-warnings --graceful-warnings + status="${PIPESTATUS[0]}" + + # PHPStan exits 1 when it has errors to report, which is the expected case here and + # is what the annotations are for. Anything higher means it did not finish at all, + # which would otherwise pass silently, since the discarded status was the only sign. + if [ "${status}" -gt 1 ]; then + echo "::error title=PHPStan did not complete::The analysis exited with status ${status}, so the code was not fully checked. This is a failure of the run itself rather than a problem found in the code." + exit "${status}" + fi + + # An ignored error that no longer occurs, or occurs a different number of times, is + # reported under an `ignore.*` identifier. That is not something to fix in the code: the + # usual cause is that the error *was* fixed, leaving a baseline describing a state that + # no longer exists. PHPStan does not allow those reports to be ignored or baselined. + # + # The analysis above is reported as warnings, so this would otherwise surface as a + # passing run carrying an annotation that reads like a complaint about a fix. Call it + # out on its own, and say what to do about it. + # + # Detection is on the identifier rather than the message, which is prose and may be + # reworded in any release. The checkstyle format carries it in the `source` attribute. + - name: Check whether the baselines need regenerating + if: ${{ !cancelled() }} + env: + BASELINES_URL: ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}/tests/phpstan/baselines + README_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}/tests/phpstan/README.md + run: | + # This step runs even when the analysis above it failed, in which case the report may + # never have been written. That failure is reported there, so there is nothing to add + # here beyond staying quiet about a file that was never going to exist. + if [ ! -f "${RUNNER_TEMP}/phpstan-report.xml" ]; then + exit 0 + fi + + # Leave the run alone unless PHPStan reported an ignore error, because everything + # below concerns an ignore configuration that no longer describes the code, and + # nothing else. Those errors are `ignore.unmatched`, where a pattern matched nothing + # at all, and `ignore.count`, where it matched a different number of times than the + # entry records. The `ignore.` prefix is matched rather than those two names so that + # any later addition to the group is caught as well. + if ! grep -q 'source="ignore\.' "${RUNNER_TEMP}/phpstan-report.xml"; then + exit 0 + fi + + # The summary is Markdown, and its code spans and fences are written literally, so the + # heredoc is quoted to keep the backticks out of the shell's hands. The links are + # written in reference style for the same reason: the URLs are the only part needing + # a variable, so defining them afterwards keeps the whole of the prose in here. + # + # A newline renders as a line break rather than a space, so each paragraph is one + # line however long that makes it, and the rendered summary wraps to its own width. + cat >> "${GITHUB_STEP_SUMMARY}" <<'SUMMARY' + ## PHPStan baselines are out of date + + An ignored error no longer occurs, or occurs a different number of times, so PHPStan reported it under an `ignore.unmatched` or `ignore.count` identifier. + + **If you fixed the error, this is expected.** Each baseline entry records an exact count for a specific file, so that a new occurrence of an already baselined error is reported rather than absorbed. That same exactness means fixing one leaves the baseline describing a state that no longer exists. There is nothing to fix in the code; the baselines just need to catch up. + + Regenerate them and commit the result: + + ```bash + npm run typecheck:php:baselines + ``` + + or, outside the Docker environment: + + ```bash + composer phpstan:baselines + ``` + + That rewrites the files under [`tests/phpstan/baselines`][baselines], deletes any whose errors are now all fixed, and updates the list of them in `phpstan.neon.dist`. + + Where the report names an `@phpstan-ignore` annotation in the code rather than a baseline entry, remove that annotation instead; regenerating will not clear it. + + See [`tests/phpstan/README.md`][readme] for details. + + SUMMARY + + { + echo "[baselines]: ${BASELINES_URL}" + echo "[readme]: ${README_URL}" + } >> "${GITHUB_STEP_SUMMARY}" + + echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:baselines\` or \`composer phpstan:baselines\` and commit the updated baselines. See ${README_URL}" + exit 1 - name: "Save result cache" uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/composer.json b/composer.json index 1bff1b4d62dd7..a04cdedd18d84 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ }, "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", + "phpstan:baselines": [ "Composer\\Config::disableProcessTimeout", "@php ./tests/phpstan/generate-baselines.php" ], "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", "format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source", "lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source", diff --git a/package.json b/package.json index d854406d50d7e..5264d752b8e4e 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,8 @@ "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", - "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", + "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --", + "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 93e6c1f6653b3..778b24b78c465 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -14,15 +14,35 @@ includes: # new strict rules. - vendor/phpstan/phpstan-phpunit/extension.neon - # The baseline file includes preexisting errors in the codebase that should be ignored. + # Preexisting errors that should be ignored, one baseline per error identifier + # so that the remaining work on each is visible as a single shrinking file. + # Each is meant to reach zero and be deleted, taking its line below with it. # https://phpstan.org/user-guide/baseline - - tests/phpstan/baseline.php + # + # Regenerate with `composer phpstan:baselines`, which rewrites both the files + # and the list between the markers. Do not edit that list by hand. + # phpstan:baselines start + - tests/phpstan/baselines/empty.variable.neon + - tests/phpstan/baselines/isset.variable.neon + - tests/phpstan/baselines/variable.undefined.neon + # phpstan:baselines end parameters: # https://phpstan.org/user-guide/rule-levels - level: 0 + level: 1 reportUnmatchedIgnoredErrors: true + # The following ignored errors are not intended to be fixed, as distinct from the baselines + # included above. + # + # A baseline records work still to be done. Every entry in one is in scope to be fixed, and + # each file is meant to reach zero and then be deleted. An entry here is the opposite: a + # decision that the code is right as written and the report is not actionable, whether + # because PHPStan cannot see what makes the code safe, or because satisfying it would mean + # changing code that has no other reason to change. + # + # So prefer fixing an error, and baseline it when it cannot be fixed yet. Add it here only + # when it should never be fixed, and say why. ignoreErrors: # Level 0: - # Inner functions aren't supported by PHPStan. @@ -40,6 +60,7 @@ parameters: identifier: function.inner path: src/wp-includes/canonical.php count: 1 + # Level 2: # ValueError is PHP 8.0+; core throws it conditionally so the docblocks are correct for WP's 7.4+ range, # but bleedingEdge's version-aware check treats the class as non-existent against the PHP 7.4 floor. diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 036f4b98432e3..edf96fefdc093 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -39,6 +39,8 @@ composer run phpstan -- src/wp-includes/template.php composer run phpstan -- -vvv --debug ``` +Note the `--` in each of those. Composer needs it in order to pass the flags on to PHPStan rather than reading them as its own, and without it they are discarded silently. The npm script supplies it, which is why only one is needed there. + For available flags, see https://phpstan.org/user-guide/command-line-usage. ## The PHPStan configuration @@ -91,20 +93,70 @@ PHPStan errors can be ignored in the following ways: - Adding the error pattern to the `ignoreErrors` section of the `phpstan.neon.dist` configuration file. This should be used to handle conflicts with WordPress Coding Standards or similar project decisions, or to allowlist legacy code that is not worth refactoring solely to satisfy the tests. -- Adding an error to the "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. +- Adding an error to a "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. Baselines are a useful triage tool for handling PHPStan errors in legacy code, as they allow us to enforce stricter code quality checks on new code, while gradually chipping away at the existing issues over time. **Avoid adding PHPStan errors from new code whenever possible, and use baselines as a last resort.** - The baseline file is located at `tests/phpstan/baseline.php` and generated by running PHPStan with the `--generate-baseline` flag: +### How the baselines are organized + +The baselines live in [`baselines/`](baselines), one file per error identifier, such as `variable.undefined.neon`. Splitting them this way keeps each kind of error visible as a single file that should shrink to nothing and then be deleted, rather than as part of one large file in which every kind is mixed together. + +Every entry is scoped to the file the error occurs in and carries an exact occurrence count: + +```neon +- + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-trackback.php +``` + +Both the path and the count matter. A new occurrence of an already baselined error does not match the entry, even in a file that is already listed, and is reported as a new error. That is the point of recording them this way: the baselines describe exactly what exists today, so nothing new slips in behind them. + +The consequence is that **fixing a baselined error means regenerating its baseline as part of the same change**, because the count no longer matches. A count that no longer matches is reported as an `ignore.count` error, which PHPStan does not allow to be ignored or baselined. + +### Regenerating the baselines + +The baselines are generated, and should not be edited by hand. Regenerate them with: + +```bash +npm run typecheck:php:baselines +``` + +which will run the generator in the Docker container. + +As with the analysis itself, flags are passed by adding `--` followed by the flags themselves: + +```bash +# a single identifier: +npm run typecheck:php:baselines -- --identifier=variable.undefined + +# several, either comma separated or by repeating the option: +npm run typecheck:php:baselines -- --identifier=variable.undefined,isset.variable +npm run typecheck:php:baselines -- --identifier=isset.variable --identifier=empty.variable + +# print every error as one baseline, writing nothing: +npm run typecheck:php:baselines -- --combined + +# the remaining options: +npm run typecheck:php:baselines -- --help +``` + +If you are not using the Docker environment, you can run the generator via Composer directly: + +```bash +composer phpstan:baselines + +composer phpstan:baselines -- --identifier=variable.undefined +composer phpstan:baselines -- --combined +composer phpstan:baselines -- --help +``` - ```bash - npm run typecheck:php -- --generate-baseline=tests/phpstan/baseline.php +Note the `--` in each of those. Composer needs it in order to pass the flags on to the script rather than reading them as its own, and without it they are discarded silently, so `composer phpstan:baselines --identifier=variable.undefined` regenerates every baseline rather than that one. The npm script supplies it, which is why only one is needed there. - # or, with Composer directly: - composer run phpstan -- --generate-baseline=tests/phpstan/baseline.php - ``` +A run also deletes any baseline whose identifier no longer reports anything, and rewrites the list of them between the `# phpstan:baselines` markers in the `includes` of [`phpstan.neon.dist`](../../phpstan.neon.dist) to match. Adding a newly split out baseline, and retiring one that has reached zero, therefore need no edit of the configuration. - This will regenerate the baseline file with any new errors added to the existing ones. You can then commit the updated baseline file. +PHPStan's own `--generate-baseline` is deliberately not used directly. It captures every error a run reports, with no way to restrict it to one identifier, so it cannot refresh a single baseline without sweeping every other kind of error into it. ## Performance and troubleshooting diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 71c0fa6ab6cdd..1c416cb3fe643 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -75,6 +75,8 @@ parameters: - ALLOW_SUBDIRECTORY_INSTALL - AUTH_SALT - AUTOMATIC_UPDATER_DISABLED + - BACKGROUND_COLOR + - BACKGROUND_IMAGE - COOKIEPATH - CUSTOM_TAGS - DISALLOW_FILE_EDIT @@ -82,8 +84,13 @@ parameters: - EMPTY_TRASH_DAYS - ENFORCE_GZIP - FORCE_SSL_LOGIN + - HEADER_IMAGE + - HEADER_IMAGE_HEIGHT + - HEADER_IMAGE_WIDTH + - HEADER_TEXTCOLOR - MEDIA_TRASH - MULTISITE + - NO_HEADER_TEXT - NOBLOGREDIRECT - SAVEQUERIES - SCRIPT_DEBUG diff --git a/tests/phpstan/baseline.php b/tests/phpstan/baseline.php deleted file mode 100644 index 646cbdbef630c..0000000000000 --- a/tests/phpstan/baseline.php +++ /dev/null @@ -1,3 +0,0 @@ - all-errors.neon + * + * @package WordPress + */ + +namespace WordPress\PHPStan; + +if ( 'cli' !== PHP_SAPI ) { + fwrite( STDERR, "This script must be run from the command line.\n" ); + exit( 1 ); +} + +$repo_root = dirname( __DIR__, 2 ); + +// $argv is only populated when register_argc_argv is on, so read it defensively. +$args = array(); +foreach ( (array) ( $_SERVER['argv'] ?? array() ) as $arg ) { + if ( is_string( $arg ) ) { + $args[] = $arg; + } +} +array_shift( $args ); + +$config_option = 'phpstan.neon.dist'; +$output_option = 'tests/phpstan/baselines'; +$memory_limit = '2G'; +$only_identifiers = array(); +$combined = false; + +foreach ( $args as $arg ) { + if ( '--help' === $arg || '-h' === $arg ) { + fwrite( STDOUT, get_usage() ); + exit( 0 ); + } + + if ( '--combined' === $arg ) { + $combined = true; + continue; + } + + if ( 1 === preg_match( '/^--identifier=(.+)$/', $arg, $matches ) ) { + foreach ( explode( ',', $matches[1] ) as $identifier ) { + $identifier = trim( $identifier ); + if ( '' !== $identifier ) { + $only_identifiers[] = $identifier; + } + } + continue; + } + + if ( 1 === preg_match( '/^--config=(.+)$/', $arg, $matches ) ) { + $config_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--output-dir=(.+)$/', $arg, $matches ) ) { + $output_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--memory-limit=(.+)$/', $arg, $matches ) ) { + $memory_limit = $matches[1]; + continue; + } + + fwrite( STDERR, "Unrecognized option: $arg\n\n" . get_usage() ); + exit( 1 ); +} + +$config_path = $repo_root . '/' . ltrim( $config_option, '/' ); +$output_dir = $repo_root . '/' . trim( $output_option, '/' ); + +if ( ! is_file( $config_path ) ) { + fwrite( STDERR, "Configuration not found: $config_option\n" ); + exit( 1 ); +} + +/* + * The temporary configuration has to sit beside the original, because a neon + * file's `includes` entries resolve relative to its own directory. + */ +/* + * Both temporary files sit beside the configuration, and so inside the + * repository, for two separate reasons. + * + * A neon file's `includes` resolve relative to its own directory, so the copy of + * the configuration has to live where the original did. + * + * PHPStan writes a PHP baseline's paths as __DIR__ followed by a relative chain, + * which it can only produce when the baseline shares an ancestry with the files + * it names. Generated somewhere else, the system temporary directory included, + * it emits `__DIR__ . '//absolute/path'` instead, and every path in it then + * resolves to somewhere under that directory rather than to the source file. + */ +$temp_config = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.neon'; +$temp_baseline = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.php'; + +register_shutdown_function( + static function () use ( $temp_config, $temp_baseline ): void { + foreach ( array( $temp_config, $temp_baseline ) as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + } +); + +file_put_contents( $temp_config, strip_baseline_includes( $config_path, $output_dir ) ); + +/* + * PHPStan reports on stdout, which --combined reserves for the baseline itself, + * so its output is sent to stderr. That keeps it visible on a terminal while + * leaving stdout parseable when it is redirected. + */ +$command = sprintf( + '%s analyse --configuration=%s --generate-baseline=%s --allow-empty-baseline --no-progress --memory-limit=%s 1>&2', + escapeshellarg( $repo_root . '/vendor/bin/phpstan' ), + escapeshellarg( $temp_config ), + escapeshellarg( $temp_baseline ), + escapeshellarg( $memory_limit ) +); + +fwrite( STDERR, "Analyzing with $config_option, existing baselines suppressed...\n" ); + +$exit_code = 0; +passthru( $command, $exit_code ); + +if ( 0 !== $exit_code || ! is_file( $temp_baseline ) ) { + fwrite( STDERR, "PHPStan failed, nothing written.\n" ); + exit( 1 ); +} + +/** + * The entries of each error, grouped by the identifier of the error it suppresses. + * + * @var array, path: non-empty-string}>> $grouped + */ +$grouped = array(); + +foreach ( read_baseline( $temp_baseline ) as $entry ) { + $grouped[ $entry['identifier'] ][] = $entry; +} +ksort( $grouped ); + +if ( $only_identifiers ) { + $grouped = array_intersect_key( $grouped, array_flip( $only_identifiers ) ); +} + +if ( $combined ) { + $all = array(); + foreach ( $grouped as $entries ) { + $all = array_merge( $all, $entries ); + } + echo build_baseline( $all, $output_dir, "# Every identifier, combined.\n" ); + exit( 0 ); +} + +if ( ! is_dir( $output_dir ) && ! mkdir( $output_dir, 0755, true ) ) { + fwrite( STDERR, "Could not create $output_option\n" ); + exit( 1 ); +} + +foreach ( $grouped as $identifier => $entries ) { + file_put_contents( + $output_dir . '/' . $identifier . '.neon', + build_baseline( $entries, $output_dir, build_baseline_header( $identifier, $config_option ) ) + ); + + printf( + "%s: %d entries, %d errors\n", + $output_option . '/' . $identifier . '.neon', + count( $entries ), + count_errors( $entries ) + ); +} + +/* + * An identifier that reports nothing has been driven to zero, so retire its file + * rather than leaving a stale one behind whose entries would then be reported as + * unmatched ignores. + * + * A run restricted to particular identifiers only knows about those, so it may + * only retire those. A full run has seen everything and may retire any file that + * no longer corresponds to a reported identifier. + */ +$retired = $only_identifiers; + +if ( ! $only_identifiers ) { + foreach ( find_baselines( $output_dir ) as $file ) { + $retired[] = basename( $file, '.neon' ); + } +} + +foreach ( $retired as $identifier ) { + if ( isset( $grouped[ $identifier ] ) ) { + continue; + } + + $file = $output_dir . '/' . $identifier . '.neon'; + if ( is_file( $file ) && unlink( $file ) ) { + printf( "%s: no errors remain, file deleted.\n", $output_option . '/' . $identifier . '.neon' ); + } else { + printf( "%s: no errors reported.\n", $identifier ); + } +} + +update_config_includes( $config_path, $config_option, $output_dir ); + +/** + * Returns the usage message. + * + * @return non-falsy-string Usage message. + */ +function get_usage(): string { + return <<<'TEXT' + Generates PHPStan baselines split by error identifier. + + Writes one baseline per identifier, retires any whose identifier no longer + reports anything, and rewrites the list of them between the + `# phpstan:baselines` markers in the configuration's `includes`, so that + neither addition nor removal has to be done by hand. + + Usage: + composer phpstan:baselines [-- ] + + Options: + --identifier= Only write the baseline for this identifier. Repeatable, + or comma separated. When an identifier is named and the + analysis reports none of it, its baseline file is deleted + rather than left behind empty. + Default: every identifier reported. + --config= Configuration to analyze with, relative to the repository + root. Default: phpstan.neon.dist + --output-dir= Where the per-identifier baselines are written, relative + to the repository root. Paths inside them are written + relative to this directory. + Default: tests/phpstan/baselines + --combined Print one combined baseline to stdout instead of writing + per-identifier files. Nothing is written to disk. + --memory-limit= Passed through to PHPStan. Default: 2G + -h, --help Show this message. + + Examples: + Refresh every baseline: + composer phpstan:baselines + + Refresh one: + composer phpstan:baselines -- --identifier=variable.undefined + + Refresh several, either comma separated or by repeating the option: + composer phpstan:baselines -- --identifier=variable.undefined,isset.variable + composer phpstan:baselines -- --identifier=isset.variable --identifier=empty.variable + + Inspect everything as one baseline without writing any files: + composer phpstan:baselines -- --combined + + TEXT; +} + +/** + * Reads a file, failing loudly rather than continuing with false. + * + * @param non-falsy-string $path Absolute path to the file. + * @return string File contents. + */ +function read_file( string $path ): string { + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + fwrite( STDERR, "Could not read $path\n" ); + exit( 1 ); + } + + return $contents; +} + +/** + * Reads a baseline generated in PHPStan's PHP format. + * + * The file returns the entries as an array, so it is required rather than + * parsed. Its `path` values are built from __DIR__ and so arrive absolute. + * + * @param non-falsy-string $path Absolute path to the generated baseline. + * @return list, path: non-empty-string}> Baseline entries. + */ +function read_baseline( string $path ): array { + $data = require $path; + + $parameters = is_array( $data ) ? ( $data['parameters'] ?? null ) : null; + $ignore_errors = is_array( $parameters ) ? ( $parameters['ignoreErrors'] ?? null ) : null; + + if ( ! is_array( $ignore_errors ) ) { + fwrite( STDERR, "Unexpected baseline structure in $path\n" ); + exit( 1 ); + } + + $entries = array(); + + foreach ( $ignore_errors as $entry ) { + if ( ! is_array( $entry ) + || ! isset( $entry['message'], $entry['identifier'], $entry['count'], $entry['path'] ) + || ! is_string( $entry['message'] ) + || ! is_string( $entry['identifier'] ) + || ! is_int( $entry['count'] ) + || $entry['count'] < 0 + || ! is_string( $entry['path'] ) + || '' === $entry['path'] + ) { + fwrite( STDERR, "Unexpected baseline entry in $path.\n" ); + exit( 1 ); + } + + /* + * PHPStan attaches an identifier to every error it reports, so an entry + * without a usable one means this is not a baseline that can be split by + * identifier. Skipping it would quietly drop a suppression. + */ + if ( '' === $entry['identifier'] || '0' === $entry['identifier'] ) { + fwrite( STDERR, "Baseline entry in $path has no identifier.\n" ); + exit( 1 ); + } + + $entries[] = array( + 'message' => $entry['message'], + 'identifier' => $entry['identifier'], + 'count' => $entry['count'], + 'path' => $entry['path'], + ); + } + + return $entries; +} + +/** + * Returns the configuration with any `includes` of the baseline directory removed. + * + * Those files suppress the very errors being regenerated, so they have to be out + * of the way for the analysis to report anything. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-falsy-string $output_dir Absolute path to the baseline directory. + * @return string Configuration contents. + */ +function strip_baseline_includes( string $config_path, string $output_dir ): string { + $config_dir = dirname( $config_path ); + $in_block = false; + $kept = array(); + + foreach ( explode( "\n", read_file( $config_path ) ) as $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $kept[] = $line; + continue; + } + + // A non-indented, non-blank line ends the block. + if ( $in_block && '' !== trim( $line ) && 1 !== preg_match( '/^\s/', $line ) ) { + $in_block = false; + } + + if ( $in_block && 1 === preg_match( '/^\s*-\s*(\S+)\s*$/', $line, $matches ) ) { + $included = $matches[1]; + $absolute = ( '/' === $included[0] ) ? $included : $config_dir . '/' . $included; + + if ( 0 === strpos( normalize_path( $absolute ), normalize_path( $output_dir ) . '/' ) ) { + continue; + } + } + + $kept[] = $line; + } + + return implode( "\n", $kept ); +} + +/** + * Lists the per-identifier baselines present on disk. + * + * @param non-empty-string $output_dir Absolute path to the baseline directory. + * @return list Absolute paths, sorted by name. + */ +function find_baselines( string $output_dir ): array { + $found = glob( $output_dir . '/*.neon' ); + + if ( false === $found ) { + return array(); + } + + sort( $found ); + + $files = array(); + foreach ( $found as $file ) { + if ( '' !== $file ) { + $files[] = $file; + } + } + + return $files; +} + +/** + * Rewrites the managed region of the configuration's `includes` list. + * + * The region is delimited by marker comments, so the hand written entries around + * it are never touched. Where the markers are absent they are appended to the end + * of the `includes` block, which is what happens the first time this is run + * against a configuration. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-empty-string $config_option Configuration path, as passed on the command line. + * @param non-empty-string $output_dir Absolute path to the baseline directory. + */ +function update_config_includes( string $config_path, string $config_option, string $output_dir ): void { + $start_marker = '# phpstan:baselines start'; + $end_marker = '# phpstan:baselines end'; + + $before = read_file( $config_path ); + $lines = explode( "\n", $before ); + + $start = null; + $end = null; + foreach ( $lines as $i => $line ) { + if ( $start_marker === trim( $line ) ) { + $start = $i; + } + if ( $end_marker === trim( $line ) ) { + $end = $i; + } + } + + $region = array( "\t" . $start_marker ); + foreach ( find_baselines( $output_dir ) as $file ) { + $region[] = "\t- " . get_relative_path( dirname( $config_path ), $file ); + } + $region[] = "\t" . $end_marker; + + if ( null !== $start && null !== $end && $start < $end ) { + $updated = array_merge( + array_slice( $lines, 0, $start ), + $region, + array_slice( $lines, $end + 1 ) + ); + } else { + $insert = find_includes_end( $lines ); + + if ( null === $insert ) { + fwrite( STDERR, "No `includes` block found in $config_option, left untouched.\n" ); + return; + } + + $updated = array_merge( + array_slice( $lines, 0, $insert ), + array( '' ), + $region, + array_slice( $lines, $insert ) + ); + } + + $after = implode( "\n", $updated ); + + if ( $before === $after ) { + return; + } + + file_put_contents( $config_path, $after ); + printf( "%s: `includes` updated.\n", $config_option ); +} + +/** + * Finds where the `includes` block ends. + * + * @param list $lines Configuration lines. + * @return int|null Index of the first line after the block, or null when there is none. + */ +function find_includes_end( array $lines ): ?int { + $in_block = false; + $last = null; + + foreach ( $lines as $i => $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $last = $i; + continue; + } + + if ( ! $in_block || '' === trim( $line ) ) { + continue; + } + + // A non-indented line ends the block. + if ( 1 !== preg_match( '/^\s/', $line ) ) { + break; + } + + $last = $i; + } + + return null === $last ? null : $last + 1; +} + +/** + * Resolves ".." segments in a path without requiring it to exist. + * + * @param non-empty-string $path Path to normalize. + * @return non-falsy-string Normalized path, always absolute. + */ +function normalize_path( string $path ): string { + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $parts ); + continue; + } + $parts[] = $part; + } + + return '/' . implode( '/', $parts ); +} + +/** + * Expresses one absolute path relative to a directory. + * + * A result of "0" is possible in principle, when the target is a single segment + * named "0" directly inside $from_dir, so this is non-empty rather than non-falsy. + * + * @param non-empty-string $from_dir Directory to express the path relative to. + * @param non-empty-string $to_path Path to express. + * @return non-empty-string Relative path, or "." when the two are the same. + */ +function get_relative_path( string $from_dir, string $to_path ): string { + $from = explode( '/', trim( normalize_path( $from_dir ), '/' ) ); + $to = explode( '/', trim( normalize_path( $to_path ), '/' ) ); + + while ( $from && $to && $from[0] === $to[0] ) { + array_shift( $from ); + array_shift( $to ); + } + + $relative = str_repeat( '../', count( $from ) ) . implode( '/', $to ); + + return '' === $relative ? '.' : $relative; +} + +/** + * Totals the `count` values across a set of entries. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @return int<0, max> Total number of errors. + */ +function count_errors( array $entries ): int { + $total = 0; + + foreach ( $entries as $entry ) { + $total += $entry['count']; + } + + return $total; +} + +/** + * Builds a baseline file in PHPStan's NEON format. + * + * The entry layout matches what PHPStan itself writes, so a regenerated file can + * be diffed against one it produced. Paths are rewritten relative to the file's + * own directory, since that is what a NEON `path` resolves against. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @param non-empty-string $output_dir Directory the file is written to. + * @param string $header Comment block, or an empty string. + * @return non-falsy-string Baseline file contents. + */ +function build_baseline( array $entries, string $output_dir, string $header ): string { + $contents = ( '' === $header ? '' : $header . "\n" ) . "parameters:\n\tignoreErrors:\n"; + + foreach ( $entries as $entry ) { + $contents .= "\t\t-\n" + . "\t\t\tmessage: " . quote_neon_value( $entry['message'] ) . "\n" + . "\t\t\tidentifier: " . $entry['identifier'] . "\n" + . "\t\t\tcount: " . $entry['count'] . "\n" + . "\t\t\tpath: " . get_relative_path( $output_dir, $entry['path'] ) . "\n"; + } + + return $contents; +} + +/** + * Quotes a value for NEON. + * + * A single quoted NEON string has no escape sequences other than a doubled + * quote, so the backslashes in a message pattern survive as written. This is the + * same quoting PHPStan applies when it generates a baseline itself. + * + * @param string $value Value to quote. + * @return non-falsy-string Quoted value. + */ +function quote_neon_value( string $value ): string { + return "'" . str_replace( "'", "''", $value ) . "'"; +} + +/** + * Builds the header comment for a per-identifier baseline. + * + * @param non-falsy-string $identifier Error identifier, a group followed by a code. + * @param non-empty-string $config Configuration path, as passed on the command line. + * @return non-falsy-string Comment block. + */ +function build_baseline_header( string $identifier, string $config ): string { + return << Date: Wed, 5 Aug 2026 07:45:41 +0000 Subject: [PATCH 322/336] Build/Test Tools: Raise the PHPStan rule level to 2. This rule level includes: > unknown methods checked on all expressions (not just `$this`), validating PHPDocs Baselines are regenerated for errors at this level. Developed in https://github.com/WordPress/wordpress-develop/pull/12852. Follow-up to r61699, r63019. Props westonruter, apermo. See #64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63020 602fd350-edb4-49c9-b593-d223f7449a82 --- phpstan.neon.dist | 21 +- tests/phpstan/baselines/arguments.count.neon | 50 +++ tests/phpstan/baselines/binaryOp.invalid.neon | 35 ++ tests/phpstan/baselines/class.nameCase.neon | 25 ++ tests/phpstan/baselines/class.notFound.neon | 90 +++++ .../encapsedStringPart.nonString.neon | 25 ++ tests/phpstan/baselines/greater.invalid.neon | 25 ++ tests/phpstan/baselines/method.nonObject.neon | 55 +++ tests/phpstan/baselines/method.notFound.neon | 45 +++ .../baselines/parameter.defaultValue.neon | 105 ++++++ .../phpstan/baselines/parameter.notFound.neon | 35 ++ .../baselines/parameter.phpDocType.neon | 25 ++ .../baselines/parameter.unresolvableType.neon | 25 ++ .../phpstan/baselines/property.nonObject.neon | 255 ++++++++++++++ .../phpstan/baselines/property.notFound.neon | 330 ++++++++++++++++++ tests/phpstan/baselines/property.private.neon | 60 ++++ .../phpstan/baselines/property.protected.neon | 60 ++++ tests/phpstan/baselines/return.missing.neon | 225 ++++++++++++ .../staticClassAccess.privateMethod.neon | 190 ++++++++++ .../phpstan/baselines/varTag.noVariable.neon | 60 ++++ 20 files changed, 1740 insertions(+), 1 deletion(-) create mode 100644 tests/phpstan/baselines/arguments.count.neon create mode 100644 tests/phpstan/baselines/binaryOp.invalid.neon create mode 100644 tests/phpstan/baselines/class.nameCase.neon create mode 100644 tests/phpstan/baselines/class.notFound.neon create mode 100644 tests/phpstan/baselines/encapsedStringPart.nonString.neon create mode 100644 tests/phpstan/baselines/greater.invalid.neon create mode 100644 tests/phpstan/baselines/method.nonObject.neon create mode 100644 tests/phpstan/baselines/method.notFound.neon create mode 100644 tests/phpstan/baselines/parameter.defaultValue.neon create mode 100644 tests/phpstan/baselines/parameter.notFound.neon create mode 100644 tests/phpstan/baselines/parameter.phpDocType.neon create mode 100644 tests/phpstan/baselines/parameter.unresolvableType.neon create mode 100644 tests/phpstan/baselines/property.nonObject.neon create mode 100644 tests/phpstan/baselines/property.notFound.neon create mode 100644 tests/phpstan/baselines/property.private.neon create mode 100644 tests/phpstan/baselines/property.protected.neon create mode 100644 tests/phpstan/baselines/return.missing.neon create mode 100644 tests/phpstan/baselines/staticClassAccess.privateMethod.neon create mode 100644 tests/phpstan/baselines/varTag.noVariable.neon diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 778b24b78c465..e96b6cfd9ce20 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,14 +22,33 @@ includes: # Regenerate with `composer phpstan:baselines`, which rewrites both the files # and the list between the markers. Do not edit that list by hand. # phpstan:baselines start + - tests/phpstan/baselines/arguments.count.neon + - tests/phpstan/baselines/binaryOp.invalid.neon + - tests/phpstan/baselines/class.nameCase.neon + - tests/phpstan/baselines/class.notFound.neon - tests/phpstan/baselines/empty.variable.neon + - tests/phpstan/baselines/encapsedStringPart.nonString.neon + - tests/phpstan/baselines/greater.invalid.neon - tests/phpstan/baselines/isset.variable.neon + - tests/phpstan/baselines/method.nonObject.neon + - tests/phpstan/baselines/method.notFound.neon + - tests/phpstan/baselines/parameter.defaultValue.neon + - tests/phpstan/baselines/parameter.notFound.neon + - tests/phpstan/baselines/parameter.phpDocType.neon + - tests/phpstan/baselines/parameter.unresolvableType.neon + - tests/phpstan/baselines/property.nonObject.neon + - tests/phpstan/baselines/property.notFound.neon + - tests/phpstan/baselines/property.private.neon + - tests/phpstan/baselines/property.protected.neon + - tests/phpstan/baselines/return.missing.neon + - tests/phpstan/baselines/staticClassAccess.privateMethod.neon + - tests/phpstan/baselines/varTag.noVariable.neon - tests/phpstan/baselines/variable.undefined.neon # phpstan:baselines end parameters: # https://phpstan.org/user-guide/rule-levels - level: 1 + level: 2 reportUnmatchedIgnoredErrors: true # The following ignored errors are not intended to be fixed, as distinct from the baselines diff --git a/tests/phpstan/baselines/arguments.count.neon b/tests/phpstan/baselines/arguments.count.neon new file mode 100644 index 0000000000000..e3cab51b3621e --- /dev/null +++ b/tests/phpstan/baselines/arguments.count.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `arguments.count` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/arguments.count +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=arguments.count +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method WP_List_Table\:\:display_rows\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_List_Table\:\:single_row\(\) invoked with 2 parameters, 1 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_List_Table\:\:single_row\(\) invoked with 3 parameters, 1 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Method WP_Upgrader_Skin\:\:before\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Method WP_Upgrader_Skin\:\:before\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 2 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Method WP_List_Table\:\:display\(\) invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 1 + path: ../../../src/wp-admin/includes/meta-boxes.php diff --git a/tests/phpstan/baselines/binaryOp.invalid.neon b/tests/phpstan/baselines/binaryOp.invalid.neon new file mode 100644 index 0000000000000..167f159cffb34 --- /dev/null +++ b/tests/phpstan/baselines/binaryOp.invalid.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `binaryOp.invalid` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/binaryOp.invalid +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=binaryOp.invalid +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Binary operation "/" between string and 2 results in an error\.$#' + identifier: binaryOp.invalid + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Binary operation "\+" between array\\|WP_Comment\>\|int\<1, max\> and 1 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Binary operation "\+" between string and int results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/class.nameCase.neon b/tests/phpstan/baselines/class.nameCase.neon new file mode 100644 index 0000000000000..dd88c5b677aa8 --- /dev/null +++ b/tests/phpstan/baselines/class.nameCase.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `class.nameCase` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/class.nameCase +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=class.nameCase +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Class MO referenced with incorrect case\: Mo\.$#' + identifier: class.nameCase + count: 1 + path: ../../../src/wp-includes/class-wp-locale-switcher.php diff --git a/tests/phpstan/baselines/class.notFound.neon b/tests/phpstan/baselines/class.notFound.neon new file mode 100644 index 0000000000000..560ceafb56573 --- /dev/null +++ b/tests/phpstan/baselines/class.notFound.neon @@ -0,0 +1,90 @@ +# PHPStan baseline for the `class.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/class.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=class.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Filesystem_FTPext\:\:\$link has unknown class FTP\\Connection as its type\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-filesystem-ftpext.php + - + message: '#^Function _crop_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function _flip_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function _rotate_image_resource\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _crop_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _flip_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \$img of function _rotate_image_resource\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Function load_image_to_edit\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to method html\(\) on an unknown class WP_Press_This_Plugin\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-admin/press-this.php + - + message: '#^Method WP_Image_Editor_GD\:\:_resize\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \$image of method WP_Image_Editor_GD\:\:_save\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Property WP_Image_Editor_GD\:\:\$image has unknown class GdImage as its type\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Function wp_imagecreatetruecolor\(\) has invalid return type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Parameter \$image of function is_gd_image\(\) has invalid type GdImage\.$#' + identifier: class.notFound + count: 1 + path: ../../../src/wp-includes/media.php diff --git a/tests/phpstan/baselines/encapsedStringPart.nonString.neon b/tests/phpstan/baselines/encapsedStringPart.nonString.neon new file mode 100644 index 0000000000000..707a2192a4c8a --- /dev/null +++ b/tests/phpstan/baselines/encapsedStringPart.nonString.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `encapsedStringPart.nonString` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/encapsedStringPart.nonString +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=encapsedStringPart.nonString +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Part \$form_fields\[''_final''\] \(non\-empty\-array\\) of encapsed string cannot be cast to string\.$#' + identifier: encapsedStringPart.nonString + count: 1 + path: ../../../src/wp-admin/includes/media.php diff --git a/tests/phpstan/baselines/greater.invalid.neon b/tests/phpstan/baselines/greater.invalid.neon new file mode 100644 index 0000000000000..bf9ca0aa0250f --- /dev/null +++ b/tests/phpstan/baselines/greater.invalid.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `greater.invalid` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/greater.invalid +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=greater.invalid +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\>" between \*NEVER\* and 0 results in an error\.$#' + identifier: greater.invalid + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php diff --git a/tests/phpstan/baselines/method.nonObject.neon b/tests/phpstan/baselines/method.nonObject.neon new file mode 100644 index 0000000000000..4d0727f8950f1 --- /dev/null +++ b/tests/phpstan/baselines/method.nonObject.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `method.nonObject` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.nonObject +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.nonObject +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot call method inline_edit\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/edit-tags.php + - + message: '#^Cannot call method inline_edit\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Cannot call method embed_scripts\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Cannot call method process_bulk_action\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Cannot call method embed_scripts\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Cannot call method process_bulk_action\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Cannot call method theme_installer_single\(\) on WP_List_Table\|false\.$#' + identifier: method.nonObject + count: 1 + path: ../../../src/wp-admin/includes/theme-install.php diff --git a/tests/phpstan/baselines/method.notFound.neon b/tests/phpstan/baselines/method.notFound.neon new file mode 100644 index 0000000000000..5d6bccb070ecb --- /dev/null +++ b/tests/phpstan/baselines/method.notFound.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `method.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method WP_Upgrader\:\:get_name_for_update\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:plugin_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-installer-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:plugin_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:theme_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-installer-skin.php + - + message: '#^Call to an undefined method WP_Upgrader\:\:theme_info\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader-skin.php diff --git a/tests/phpstan/baselines/parameter.defaultValue.neon b/tests/phpstan/baselines/parameter.defaultValue.neon new file mode 100644 index 0000000000000..34b6b678fc447 --- /dev/null +++ b/tests/phpstan/baselines/parameter.defaultValue.neon @@ -0,0 +1,105 @@ +# PHPStan baseline for the `parameter.defaultValue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.defaultValue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.defaultValue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Default value of the parameter \#1 \$admin_header_callback \(''''\) of method Custom_Background\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Default value of the parameter \#2 \$admin_image_div_callback \(''''\) of method Custom_Background\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Default value of the parameter \#2 \$admin_image_div_callback \(''''\) of method Custom_Image_Header\:\:__construct\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_comments_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_dashboard_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_links_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_management_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_media_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_menu_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_options_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_pages_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_plugins_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_posts_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_theme_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#5 \$callback \(''''\) of function add_users_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#6 \$callback \(''''\) of function add_submenu_page\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Default value of the parameter \#3 \$deprecated \(''''\) of function unregister_setting\(\) is incompatible with type callable\(\)\: mixed\.$#' + identifier: parameter.defaultValue + count: 1 + path: ../../../src/wp-includes/option.php diff --git a/tests/phpstan/baselines/parameter.notFound.neon b/tests/phpstan/baselines/parameter.notFound.neon new file mode 100644 index 0000000000000..c3835cd7903c4 --- /dev/null +++ b/tests/phpstan/baselines/parameter.notFound.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `parameter.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param references unknown parameter\: \$key$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^PHPDoc tag @param references unknown parameter\: \$url$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^PHPDoc tag @param references unknown parameter\: \$value$#' + identifier: parameter.notFound + count: 1 + path: ../../../src/wp-includes/functions.php diff --git a/tests/phpstan/baselines/parameter.phpDocType.neon b/tests/phpstan/baselines/parameter.phpDocType.neon new file mode 100644 index 0000000000000..db8a7f3b32466 --- /dev/null +++ b/tests/phpstan/baselines/parameter.phpDocType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `parameter.phpDocType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.phpDocType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.phpDocType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param for parameter \$block_type with type array\ is incompatible with native type string\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php diff --git a/tests/phpstan/baselines/parameter.unresolvableType.neon b/tests/phpstan/baselines/parameter.unresolvableType.neon new file mode 100644 index 0000000000000..1193e163ca555 --- /dev/null +++ b/tests/phpstan/baselines/parameter.unresolvableType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `parameter.unresolvableType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameter.unresolvableType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameter.unresolvableType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @param for parameter \$type contains unresolvable type\.$#' + identifier: parameter.unresolvableType + count: 1 + path: ../../../src/wp-includes/class-wp-feed-cache-transient.php diff --git a/tests/phpstan/baselines/property.nonObject.neon b/tests/phpstan/baselines/property.nonObject.neon new file mode 100644 index 0000000000000..81a4af3e09511 --- /dev/null +++ b/tests/phpstan/baselines/property.nonObject.neon @@ -0,0 +1,255 @@ +# PHPStan baseline for the `property.nonObject` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.nonObject +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.nonObject +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$id on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$link on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Cannot access property \$current on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$response on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Cannot access property \$info on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Cannot access property \$plugins on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Cannot access property \$parent on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php + - + message: '#^Cannot access property \$term_id on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php + - + message: '#^Cannot access property \$info on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-theme-install-list-table.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/class-wp-theme-install-list-table.php + - + message: '#^Cannot access property \$author on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$downloaded on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$external on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$homepage on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$requires on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$sections on array\|object\.$#' + identifier: property.nonObject + count: 5 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$slug on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$tested on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Cannot access property \$meta_key on object\|true\.$#' + identifier: property.nonObject + count: 4 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Cannot access property \$post_id on object\|true\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$name on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$version on array\|object\.$#' + identifier: property.nonObject + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Cannot access property \$comment_shortcuts on WP_User\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Cannot access property \$infinite_scrolling on WP_User\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Cannot access property \$id on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Cannot access property \$link on int\|string\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Cannot access property \$themes on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Cannot access property \$object_id on array\|WP_Error\|WP_Term\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Cannot access property \$term_id on string\|WP_Customize_Setting\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-control.php + - + message: '#^Cannot access property \$link_id on array\|object\.$#' + identifier: property.nonObject + count: 3 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Cannot access property \$plugins on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php + - + message: '#^Cannot access property \$auto_add on WP_Term\|false\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php + - + message: '#^Cannot access property \$download_link on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Cannot access property \$language_packs on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Cannot access property \$parent on array\|object\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Cannot access property \$template_name on array\.$#' + identifier: property.nonObject + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Cannot access property \$term_id on array\|object\.$#' + identifier: property.nonObject + count: 4 + path: ../../../src/wp-includes/taxonomy.php diff --git a/tests/phpstan/baselines/property.notFound.neon b/tests/phpstan/baselines/property.notFound.neon new file mode 100644 index 0000000000000..7893a2dd24bb6 --- /dev/null +++ b/tests/phpstan/baselines/property.notFound.neon @@ -0,0 +1,330 @@ +# PHPStan baseline for the `property.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$language_update\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader\:\:\$new_plugin_data\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-installer-skin.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$plugin_active\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$plugin_info\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-plugin-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader\:\:\$new_theme_data\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-installer-skin.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$api\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Access to an undefined property WP_Upgrader_Skin\:\:\$theme_info\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-theme-upgrader.php + - + message: '#^Access to an undefined property WP_Post\:\:\$attr_title\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$menu_item_parent\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object_id\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$target\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$xfn\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$menu_item_parent\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$object_id\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$target\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type_label\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Post\:\:\$xfn\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$author\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$name\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$parent_theme\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$version\.$#' + identifier: property.notFound + count: 5 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$auto_update_forced\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$update_supported\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$name\.$#' + identifier: property.notFound + count: 8 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Access to an undefined property WP_Post\:\:\$_wp_attachment_image_alt\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Access to an undefined property WP_Post\:\:\$front_or_home\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Access to an undefined property WP_Post\:\:\$privacy_policy_page\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Access to an undefined property wpdb\:\:\$categories\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property wpdb\:\:\$link2cat\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property wpdb\:\:\$post2cat\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Access to an undefined property WP_Term\:\:\$truncated_name\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Access to an undefined property WP_Theme\:\:\$version\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-admin/update-core.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-content/themes/twentynineteen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/icon-functions.php + - + message: '#^Access to an undefined property WP_Post\:\:\$classes\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/menu-functions.php + - + message: '#^Access to an undefined property WP_Post_Type\:\:\$capabilities\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Access to an undefined property WP_Term\:\:\$link\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Access to an undefined property WP_Post\:\:\$current\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Access to an undefined property WP_Post\:\:\$title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Access to an undefined property WP_Query\:\:\$comments_by_type\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Access to an undefined property WP_Post\:\:\$attr_title\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$db_id\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$description\.$#' + identifier: property.notFound + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type\.$#' + identifier: property.notFound + count: 3 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$type_label\.$#' + identifier: property.notFound + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Access to an undefined property WP_Post\:\:\$url\.$#' + identifier: property.notFound + count: 4 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php diff --git a/tests/phpstan/baselines/property.private.neon b/tests/phpstan/baselines/property.private.neon new file mode 100644 index 0000000000000..900a8e51dc423 --- /dev/null +++ b/tests/phpstan/baselines/property.private.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `property.private` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.private +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.private +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to private property WP_Theme\:\:\$stylesheet\.$#' + identifier: property.private + count: 20 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to private property WP_Theme\:\:\$template\.$#' + identifier: property.private + count: 2 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Access to private property WP_Block_Type\:\:\$variations\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Access to private property WP_Object_Cache\:\:\$cache\.$#' + identifier: property.private + count: 2 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Access to private property WP_Block_Type\:\:\$uses_context\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php + - + message: '#^Access to private property WP_Block_Type\:\:\$variations\.$#' + identifier: property.private + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php diff --git a/tests/phpstan/baselines/property.protected.neon b/tests/phpstan/baselines/property.protected.neon new file mode 100644 index 0000000000000..b29d125bd2a7a --- /dev/null +++ b/tests/phpstan/baselines/property.protected.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `property.protected` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.protected +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.protected +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/erase-personal-data.php + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/export-personal-data.php + - + message: '#^Access to protected property WP_List_Table\:\:\$screen\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Access to protected property wpdb\:\:\$dbh\.$#' + identifier: property.protected + count: 3 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbhost\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbname\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property wpdb\:\:\$dbuser\.$#' + identifier: property.protected + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Access to protected property WP_Object_Cache\:\:\$global_groups\.$#' + identifier: property.protected + count: 2 + path: ../../../src/wp-includes/ms-blogs.php diff --git a/tests/phpstan/baselines/return.missing.neon b/tests/phpstan/baselines/return.missing.neon new file mode 100644 index 0000000000000..11bb654bafb2c --- /dev/null +++ b/tests/phpstan/baselines/return.missing.neon @@ -0,0 +1,225 @@ +# PHPStan baseline for the `return.missing` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.missing +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.missing +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method Twenty_Eleven_Ephemera_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Method Twenty_Fourteen_Ephemera_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Function get_category_by_path\(\) should return array\|WP_Error\|WP_Term\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/category.php + - + message: '#^Method WP_Customize_Manager\:\:get_control\(\) should return WP_Customize_Control\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_panel\(\) should return WP_Customize_Panel\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_section\(\) should return WP_Customize_Section\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Manager\:\:get_setting\(\) should return WP_Customize_Setting\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Method WP_Customize_Widgets\:\:get_setting_type\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-customize-widgets.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Customize_Header_Image_Control\:\:get_current_image_src\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Function post_type_archive_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_month_title\(\) should return string\|false\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_post_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function single_term_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function the_date\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function the_modified_date\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function wp_title\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function edit_term_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_next_posts_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_next_posts_page_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_previous_posts_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function get_previous_posts_page_link\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function next_posts\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function previous_posts\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Function wp_list_users\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Method WP_Nav_Menu_Widget\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-nav-menu-widget.php + - + message: '#^Method WP_Widget_Archives\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-archives.php + - + message: '#^Method WP_Widget_Block\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-block.php + - + message: '#^Method WP_Widget_Calendar\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-calendar.php + - + message: '#^Method WP_Widget_Categories\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-categories.php + - + message: '#^Method WP_Widget_Custom_HTML\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-custom-html.php + - + message: '#^Method WP_Widget_Links\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-links.php + - + message: '#^Method WP_Widget_Media\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-media.php + - + message: '#^Method WP_Widget_Meta\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-meta.php + - + message: '#^Method WP_Widget_Pages\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-pages.php + - + message: '#^Method WP_Widget_Recent_Comments\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-comments.php + - + message: '#^Method WP_Widget_Recent_Posts\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-posts.php + - + message: '#^Method WP_Widget_RSS\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-rss.php + - + message: '#^Method WP_Widget_Search\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-search.php + - + message: '#^Method WP_Widget_Tag_Cloud\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/widgets/class-wp-widget-tag-cloud.php + - + message: '#^Method WP_Widget_Text\:\:form\(\) should return string\|null but return statement is missing\.$#' + identifier: return.missing + count: 2 + path: ../../../src/wp-includes/widgets/class-wp-widget-text.php diff --git a/tests/phpstan/baselines/staticClassAccess.privateMethod.neon b/tests/phpstan/baselines/staticClassAccess.privateMethod.neon new file mode 100644 index 0000000000000..c3a8969643a94 --- /dev/null +++ b/tests/phpstan/baselines/staticClassAccess.privateMethod.neon @@ -0,0 +1,190 @@ +# PHPStan baseline for the `staticClassAccess.privateMethod` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/staticClassAccess.privateMethod +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=staticClassAccess.privateMethod +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Unsafe call to private method WP_Classic_To_Block_Menu_Converter\:\:group_by_parent_id\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-classic-to-block-menu-converter.php + - + message: '#^Unsafe call to private method WP_Classic_To_Block_Menu_Converter\:\:to_blocks\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-classic-to-block-menu-converter.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:create_classic_menu_fallback\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:create_default_fallback\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_default_fallback_blocks\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_fallback_classic_menu\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_most_recently_created_nav_menu\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_most_recently_published_navigation\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_nav_menu_at_primary_location\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Navigation_Fallback\:\:get_nav_menu_with_primary_slug\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-navigation-fallback.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:inject_variations_from_block_style_variation_files\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:inject_variations_from_block_styles_registry\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:recursively_iterate_json\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:remove_json_comments\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON_Resolver\:\:style_variation_has_scope\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:compute_spacing_sizes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_block_name_from_metadata_path\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_block_nodes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_feature_selector\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:get_viewport_breakpoint_value_in_pixels\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:is_valid_viewport_breakpoint_size\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:merge_spacing_sizes\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:remove_indirect_properties\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:resolve_custom_css_format\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:sanitize_viewport_settings\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:unwrap_shared_block_style_variations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_button_width_declarations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_paragraph_text_indent_selector\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Theme_JSON\:\:update_separator_declarations\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:convert_font_face_properties\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:maybe_parse_name_from_comma_separated_list\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:parse_settings\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:to_kebab_case\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php + - + message: '#^Unsafe call to private method WP_Font_Face_Resolver\:\:to_theme_file_uri\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-face-resolver.php diff --git a/tests/phpstan/baselines/varTag.noVariable.neon b/tests/phpstan/baselines/varTag.noVariable.neon new file mode 100644 index 0000000000000..36e0f9fed1382 --- /dev/null +++ b/tests/phpstan/baselines/varTag.noVariable.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `varTag.noVariable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/varTag.noVariable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=varTag.noVariable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/profile.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-admin/upgrade.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-cron.php + - + message: '#^PHPDoc tag @var above assignment does not specify variable name\.$#' + identifier: varTag.noVariable + count: 9 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/wp-includes/kses.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 2 + path: ../../../src/wp-includes/rest-api.php + - + message: '#^PHPDoc tag @var does not specify variable name\.$#' + identifier: varTag.noVariable + count: 1 + path: ../../../src/xmlrpc.php From 8eda2df3613877b3cdfd70806f7f1ced303524ff Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 07:50:07 +0000 Subject: [PATCH 323/336] Build/Test Tools: Raise the PHPStan rule level to 3. This rule level includes: > return types, types assigned to properties Baselines are regenerated for errors at this level. Follow-up to r61699, r63019, r63020. Props westonruter, apermo. See Core-64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63021 602fd350-edb4-49c9-b593-d223f7449a82 --- phpstan.neon.dist | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e96b6cfd9ce20..43e42c278a7ce 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -23,24 +23,35 @@ includes: # and the list between the markers. Do not edit that list by hand. # phpstan:baselines start - tests/phpstan/baselines/arguments.count.neon + - tests/phpstan/baselines/assign.propertyType.neon - tests/phpstan/baselines/binaryOp.invalid.neon - tests/phpstan/baselines/class.nameCase.neon - tests/phpstan/baselines/class.notFound.neon - tests/phpstan/baselines/empty.variable.neon - tests/phpstan/baselines/encapsedStringPart.nonString.neon + - tests/phpstan/baselines/foreach.nonIterable.neon - tests/phpstan/baselines/greater.invalid.neon - tests/phpstan/baselines/isset.variable.neon + - tests/phpstan/baselines/method.childParameterType.neon - tests/phpstan/baselines/method.nonObject.neon - tests/phpstan/baselines/method.notFound.neon + - tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon + - tests/phpstan/baselines/offsetAccess.notFound.neon + - tests/phpstan/baselines/offsetAssign.valueType.neon - tests/phpstan/baselines/parameter.defaultValue.neon - tests/phpstan/baselines/parameter.notFound.neon - tests/phpstan/baselines/parameter.phpDocType.neon - tests/phpstan/baselines/parameter.unresolvableType.neon + - tests/phpstan/baselines/parameterByRef.type.neon + - tests/phpstan/baselines/property.defaultValue.neon - tests/phpstan/baselines/property.nonObject.neon - tests/phpstan/baselines/property.notFound.neon + - tests/phpstan/baselines/property.phpDocType.neon - tests/phpstan/baselines/property.private.neon - tests/phpstan/baselines/property.protected.neon + - tests/phpstan/baselines/return.empty.neon - tests/phpstan/baselines/return.missing.neon + - tests/phpstan/baselines/return.type.neon - tests/phpstan/baselines/staticClassAccess.privateMethod.neon - tests/phpstan/baselines/varTag.noVariable.neon - tests/phpstan/baselines/variable.undefined.neon @@ -48,7 +59,7 @@ includes: parameters: # https://phpstan.org/user-guide/rule-levels - level: 2 + level: 3 reportUnmatchedIgnoredErrors: true # The following ignored errors are not intended to be fixed, as distinct from the baselines From 194916fc381837f638e2784d71bd189b8856f3ad Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 08:00:29 +0000 Subject: [PATCH 324/336] Build/Test Tools: Add missing baselines from PHPStan level 3 bump. Follow-up to r63021. See #64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63022 602fd350-edb4-49c9-b593-d223f7449a82 --- .../baselines/assign.propertyType.neon | 160 +++++++++++++++++ .../baselines/foreach.nonIterable.neon | 25 +++ .../baselines/method.childParameterType.neon | 55 ++++++ .../offsetAccess.nonOffsetAccessible.neon | 35 ++++ .../baselines/offsetAccess.notFound.neon | 40 +++++ .../baselines/offsetAssign.valueType.neon | 25 +++ .../baselines/parameterByRef.type.neon | 50 ++++++ .../baselines/property.defaultValue.neon | 110 ++++++++++++ .../baselines/property.phpDocType.neon | 45 +++++ tests/phpstan/baselines/return.empty.neon | 30 ++++ tests/phpstan/baselines/return.type.neon | 165 ++++++++++++++++++ 11 files changed, 740 insertions(+) create mode 100644 tests/phpstan/baselines/assign.propertyType.neon create mode 100644 tests/phpstan/baselines/foreach.nonIterable.neon create mode 100644 tests/phpstan/baselines/method.childParameterType.neon create mode 100644 tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon create mode 100644 tests/phpstan/baselines/offsetAccess.notFound.neon create mode 100644 tests/phpstan/baselines/offsetAssign.valueType.neon create mode 100644 tests/phpstan/baselines/parameterByRef.type.neon create mode 100644 tests/phpstan/baselines/property.defaultValue.neon create mode 100644 tests/phpstan/baselines/property.phpDocType.neon create mode 100644 tests/phpstan/baselines/return.empty.neon create mode 100644 tests/phpstan/baselines/return.type.neon diff --git a/tests/phpstan/baselines/assign.propertyType.neon b/tests/phpstan/baselines/assign.propertyType.neon new file mode 100644 index 0000000000000..f53f802b7d1da --- /dev/null +++ b/tests/phpstan/baselines/assign.propertyType.neon @@ -0,0 +1,160 @@ +# PHPStan baseline for the `assign.propertyType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/assign.propertyType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=assign.propertyType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Comment\:\:\$comment_ID \(numeric\-string\) does not accept int\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Property WP_Comment\:\:\$comment_post_ID \(numeric\-string\) does not accept int\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Property WP_Block_Template\:\:\$author \(int\|null\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/block-template-utils.php + - + message: '#^Property WP_Customize_Control\:\:\$settings \(array\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Setting\:\:\$default \(string\) does not accept stdClass\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Image_Editor_Imagick\:\:\$image \(Imagick\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept list\\|null\.$#' + identifier: assign.propertyType + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$posts \(array\\|null\) does not accept list\\.$#' + identifier: assign.propertyType + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Rewrite\:\:\$rules \(array\\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Property WP_Term_Query\:\:\$terms \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$blocks \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$core \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$i18n_schema \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$theme \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$user \(WP_Theme_JSON\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Static property WP_Theme_JSON_Resolver\:\:\$user_custom_post_type_id \(int\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json-resolver.php + - + message: '#^Property WP_User\:\:\$roles \(array\\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Property wpdb\:\:\$col_info \(array\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property wpdb\:\:\$last_query \(string\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property WP_Customize_Header_Image_Control\:\:\$default_headers \(string\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Property WP_Customize_Header_Image_Control\:\:\$uploaded_headers \(string\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-header-image-control.php + - + message: '#^Property WP_HTML_Tag_Processor\:\:\$is_closing_tag \(bool\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Property WP_Translation_File\:\:\$entries \(array\\) does not accept array\\>\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-file.php + - + message: '#^Property WP_REST_Autosaves_Controller\:\:\$revisions_controller \(WP_REST_Revisions_Controller\) does not accept WP_REST_Controller\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php + - + message: '#^Property WP_REST_Template_Autosaves_Controller\:\:\$revisions_controller \(WP_REST_Revisions_Controller\) does not accept WP_REST_Controller\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php + - + message: '#^Property WP_Taxonomy\:\:\$labels \(stdClass\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-media.php diff --git a/tests/phpstan/baselines/foreach.nonIterable.neon b/tests/phpstan/baselines/foreach.nonIterable.neon new file mode 100644 index 0000000000000..be8bba17a113c --- /dev/null +++ b/tests/phpstan/baselines/foreach.nonIterable.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `foreach.nonIterable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/foreach.nonIterable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=foreach.nonIterable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Argument of an invalid type stdClass supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: ../../../src/wp-includes/class-wp-post-type.php diff --git a/tests/phpstan/baselines/method.childParameterType.neon b/tests/phpstan/baselines/method.childParameterType.neon new file mode 100644 index 0000000000000..7ddc1325be94c --- /dev/null +++ b/tests/phpstan/baselines/method.childParameterType.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `method.childParameterType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.childParameterType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.childParameterType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$comment_status \(bool\) of method WP_Post_Comments_List_Table\:\:get_per_page\(\) should be compatible with parameter \$comment_status \(string\) of method WP_Comments_List_Table\:\:get_per_page\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-admin/includes/class-wp-post-comments-list-table.php + - + message: '#^Parameter \#3 \$args \(stdClass\) of method Walker_Nav_Menu\:\:end_lvl\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:end_lvl\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#3 \$args \(stdClass\) of method Walker_Nav_Menu\:\:start_lvl\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:start_lvl\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#4 \$args \(stdClass\) of method Walker_Nav_Menu\:\:end_el\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:end_el\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#4 \$args \(stdClass\) of method Walker_Nav_Menu\:\:start_el\(\) should be compatible with parameter \$args \(array\) of method Walker\:\:start_el\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Parameter \#1 \$id \(int\) of method WP_REST_Global_Styles_Controller\:\:prepare_links\(\) should be compatible with parameter \$post \(WP_Post\) of method WP_REST_Posts_Controller\:\:prepare_links\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php + - + message: '#^Parameter \#1 \$parent_template_id \(string\) of method WP_REST_Template_Revisions_Controller\:\:get_parent\(\) should be compatible with parameter \$parent_post_id \(int\) of method WP_REST_Revisions_Controller\:\:get_parent\(\)$#' + identifier: method.childParameterType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php diff --git a/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon new file mode 100644 index 0000000000000..5b2396b941816 --- /dev/null +++ b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `offsetAccess.nonOffsetAccessible` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAccess.nonOffsetAccessible +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAccess.nonOffsetAccessible +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Cannot access offset ''new_version'' on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Cannot access offset mixed on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Cannot access offset ''new_version'' on bool\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: ../../../src/wp-admin/update-core.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon new file mode 100644 index 0000000000000..a5e2eb0698cc8 --- /dev/null +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `offsetAccess.notFound` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAccess.notFound +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAccess.notFound +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset float does not exist on list\.$#' + identifier: offsetAccess.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Offset ''preview'' does not exist on array\{activate\: non\-falsy\-string\}\.$#' + identifier: offsetAccess.notFound + count: 1 + path: ../../../src/wp-admin/includes/class-wp-themes-list-table.php + - + message: '#^Offset ''basedir'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 2 + path: ../../../src/wp-includes/fonts.php + - + message: '#^Offset ''baseurl'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 2 + path: ../../../src/wp-includes/fonts.php diff --git a/tests/phpstan/baselines/offsetAssign.valueType.neon b/tests/phpstan/baselines/offsetAssign.valueType.neon new file mode 100644 index 0000000000000..8a28f7d980320 --- /dev/null +++ b/tests/phpstan/baselines/offsetAssign.valueType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `offsetAssign.valueType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/offsetAssign.valueType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=offsetAssign.valueType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^WpOrg\\Requests\\Cookie\\Jar does not accept WpOrg\\Requests\\Cookie\.$#' + identifier: offsetAssign.valueType + count: 2 + path: ../../../src/wp-includes/class-wp-http.php diff --git a/tests/phpstan/baselines/parameterByRef.type.neon b/tests/phpstan/baselines/parameterByRef.type.neon new file mode 100644 index 0000000000000..8b7394add3c7e --- /dev/null +++ b/tests/phpstan/baselines/parameterByRef.type.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `parameterByRef.type` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameterByRef.type +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameterByRef.type +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter &\$stored_results by\-ref type of method WP_Scripts\:\:get_highest_fetchpriority_with_dependents\(\) expects array\, array\ given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-scripts.php + - + message: '#^Parameter &\$query by\-ref type of method WP_Tax_Query\:\:clean_query\(\) expects array, WP_Error given\.$#' + identifier: parameterByRef.type + count: 2 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Parameter &\$query by\-ref type of method WP_Tax_Query\:\:transform_query\(\) expects array, WP_Error given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Parameter &\$matched_token_byte_length by\-ref type of method WP_Token_Map\:\:read_token\(\) expects int\|null, \(float\|int\) given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/class-wp-token-map.php + - + message: '#^Parameter &\$has_noncharacters by\-ref type of function _wp_scan_utf8\(\) expects bool\|null, int given\.$#' + identifier: parameterByRef.type + count: 2 + path: ../../../src/wp-includes/compat-utf8.php + - + message: '#^Parameter &\$result by\-ref type of function _page_traverse_name\(\) expects array\, array given\.$#' + identifier: parameterByRef.type + count: 1 + path: ../../../src/wp-includes/post.php diff --git a/tests/phpstan/baselines/property.defaultValue.neon b/tests/phpstan/baselines/property.defaultValue.neon new file mode 100644 index 0000000000000..e604833eade20 --- /dev/null +++ b/tests/phpstan/baselines/property.defaultValue.neon @@ -0,0 +1,110 @@ +# PHPStan baseline for the `property.defaultValue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.defaultValue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.defaultValue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property Walker_Nav_Menu\:\:\$tree_type \(string\) does not accept default value of type array\\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-walker-nav-menu.php + - + message: '#^Property WP_Block\:\:\$inner_blocks \(WP_Block_List\) does not accept default value of type array\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Property WP_Comment_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Property WP_Comment_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Property WP_Customize_Control\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Panel\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Panel\:\:\$theme_supports \(array\\) does not accept default value of type string\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Section\:\:\$active_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-section.php + - + message: '#^Property WP_Customize_Setting\:\:\$sanitize_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Setting\:\:\$sanitize_js_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Setting\:\:\$validate_callback \(callable\(\)\: mixed\) does not accept default value of type ''''\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Property WP_Site_Query\:\:\$date_query \(WP_Date_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-site-query.php + - + message: '#^Property WP_Site_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-site-query.php + - + message: '#^Property WP_Term_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Property WP_Term\:\:\$term_group \(int\) does not accept default value of type string\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-term.php + - + message: '#^Property WP_User_Query\:\:\$meta_query \(WP_Meta_Query\) does not accept default value of type false\.$#' + identifier: property.defaultValue + count: 1 + path: ../../../src/wp-includes/class-wp-user-query.php diff --git a/tests/phpstan/baselines/property.phpDocType.neon b/tests/phpstan/baselines/property.phpDocType.neon new file mode 100644 index 0000000000000..2f69939d19f8b --- /dev/null +++ b/tests/phpstan/baselines/property.phpDocType.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `property.phpDocType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.phpDocType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.phpDocType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^PHPDoc type array of property WP_Customize_Nav_Menu_Item_Setting\:\:\$default is not covariant with PHPDoc type string of overridden property WP_Customize_Setting\:\:\$default\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^PHPDoc type array of property WP_Customize_Nav_Menu_Setting\:\:\$default is not covariant with PHPDoc type string of overridden property WP_Customize_Setting\:\:\$default\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^PHPDoc type false of property WP_REST_Attachments_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php + - + message: '#^PHPDoc type false of property WP_REST_Font_Faces_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php + - + message: '#^PHPDoc type false of property WP_REST_Font_Families_Controller\:\:\$allow_batch is not covariant with PHPDoc type array of overridden property WP_REST_Posts_Controller\:\:\$allow_batch\.$#' + identifier: property.phpDocType + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php diff --git a/tests/phpstan/baselines/return.empty.neon b/tests/phpstan/baselines/return.empty.neon new file mode 100644 index 0000000000000..5abf2badb5636 --- /dev/null +++ b/tests/phpstan/baselines/return.empty.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `return.empty` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.empty +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.empty +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function twentytwenty_generate_css\(\) should return string but empty return statement found\.$#' + identifier: return.empty + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/custom-css.php + - + message: '#^Function wp_dropdown_languages\(\) should return string but empty return statement found\.$#' + identifier: return.empty + count: 1 + path: ../../../src/wp-includes/l10n.php diff --git a/tests/phpstan/baselines/return.type.neon b/tests/phpstan/baselines/return.type.neon new file mode 100644 index 0000000000000..9d99e94d0abd6 --- /dev/null +++ b/tests/phpstan/baselines/return.type.neon @@ -0,0 +1,165 @@ +# PHPStan baseline for the `return.type` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.type +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.type +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Method WP_Automatic_Updater\:\:update\(\) should return WP_Error\|null but returns false\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Function convert_to_screen\(\) should return WP_Screen but returns object\{id\: string, base\: string\}&stdClass\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Function twentytwenty_get_color_for_area\(\) should return string but returns false\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Function filter_block_kses\(\) should return array but returns ArrayAccess&WP_Block_Parser_Block\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\\|null but returns array\\|string\|null\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^Method WP_Block_Type\:\:__get\(\) should return array\\|string\|null but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-type.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but returns float\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Image_Editor_Imagick\:\:write_image\(\) should return WP_Error\|true but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Method WP_Term_Query\:\:get_terms\(\) should return array\\|string but returns int\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-term-query.php + - + message: '#^Method wp_xmlrpc_server\:\:mw_newPost\(\) should return int\|IXR_Error but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Method wp_xmlrpc_server\:\:wp_newTerm\(\) should return int\|IXR_Error but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Function _upgrade_cron_array\(\) should return array\{version\: 2, \.\.\.\, interval\?\: int\<0, max\>\}\>\>\>\} but returns non\-empty\-array\<''version''\|int, array\, interval\?\: int\<0, max\>\}\>\|int\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/cron.php + - + message: '#^Method WP_Customize_Nav_Menu_Setting\:\:filter_wp_get_nav_menu_object\(\) should return object\|null but returns false\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^Function _wp_filter_font_directory\(\) should return string but returns array\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/fonts.php + - + message: '#^Method WP_Translation_Controller\:\:get_entries\(\) should return array\ but returns array\\>\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-controller.php + - + message: '#^Method WP_Translation_File\:\:entries\(\) should return array\\> but returns array\\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/l10n/class-wp-translation-file.php + - + message: '#^Function update_meta_cache\(\) should return array\|false but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/meta.php + - + message: '#^Function wp_post_revision_title\(\) should return string\|false but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Function wp_post_revision_title_expanded\(\) should return string\|false but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Function wp_set_post_categories\(\) should return array\|WP_Error\|false but returns true\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function wp_trash_post\(\) should return WP_Post\|false\|null but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function wp_untrash_post\(\) should return WP_Post\|false\|null but returns bool\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Method WP_REST_Autosaves_Controller\:\:get_item\(\) should return WP_Error\|WP_Post but returns WP_REST_Response\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php + - + message: '#^Method WP_REST_Controller\:\:get_object_type\(\) should return string but returns null\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php + - + message: '#^Method WP_REST_Template_Autosaves_Controller\:\:get_item\(\) should return WP_Error\|WP_Post but returns WP_REST_Response\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php + - + message: '#^Function _wp_preview_post_thumbnail_filter\(\) should return array\|null but returns string\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-includes/revision.php + - + message: '#^Function term_exists\(\) should return array\{term_id\: numeric\-string, term_taxonomy_id\: numeric\-string\}\|int\|null but returns string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function _wp_get_current_user\(\) should return WP_User but returns null\.$#' + identifier: return.type + count: 2 + path: ../../../src/wp-includes/user.php From 79d902a8896d90270cd75f7cfa1af1d24c9b053b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 08:27:05 +0000 Subject: [PATCH 325/336] Build/Test Tools: Raise the PHPStan rule level to 4. This rule level includes: > basic dead code checking - always false `instanceof` and other type checks, dead `else` branches, unreachable code after return; etc. Baselines are regenerated for errors at this level. Developed in https://github.com/WordPress/wordpress-develop/pull/12853. Follow-up to r61699, r63019, r63020, r63021, r63022. Props westonruter, apermo. See #64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63023 602fd350-edb4-49c9-b593-d223f7449a82 --- phpstan.neon.dist | 40 ++- .../baselines/booleanAnd.alwaysFalse.neon | 30 ++ .../baselines/booleanAnd.alwaysTrue.neon | 25 ++ .../baselines/booleanAnd.leftAlwaysTrue.neon | 45 +++ .../booleanAnd.rightAlwaysFalse.neon | 25 ++ .../baselines/booleanAnd.rightAlwaysTrue.neon | 65 ++++ .../baselines/booleanNot.alwaysFalse.neon | 50 +++ .../baselines/booleanNot.alwaysTrue.neon | 60 ++++ .../baselines/booleanOr.alwaysFalse.neon | 25 ++ .../baselines/booleanOr.alwaysTrue.neon | 30 ++ .../baselines/booleanOr.rightAlwaysTrue.neon | 25 ++ .../phpstan/baselines/catch.neverThrown.neon | 25 ++ .../baselines/deadCode.unreachable.neon | 305 ++++++++++++++++++ tests/phpstan/baselines/empty.offset.neon | 30 ++ tests/phpstan/baselines/empty.property.neon | 65 ++++ .../function.alreadyNarrowedType.neon | 105 ++++++ .../baselines/function.impossibleType.neon | 40 +++ .../baselines/function.resultUnused.neon | 40 +++ .../baselines/greaterOrEqual.alwaysTrue.neon | 60 ++++ .../baselines/identical.alwaysFalse.neon | 45 +++ .../baselines/identical.alwaysTrue.neon | 40 +++ tests/phpstan/baselines/if.alwaysFalse.neon | 55 ++++ tests/phpstan/baselines/if.alwaysTrue.neon | 55 ++++ .../baselines/instanceof.alwaysTrue.neon | 25 ++ tests/phpstan/baselines/isset.offset.neon | 40 +++ tests/phpstan/baselines/isset.property.neon | 220 +++++++++++++ tests/phpstan/baselines/method.unused.neon | 45 +++ .../baselines/notIdentical.alwaysTrue.neon | 75 +++++ .../baselines/nullCoalesce.offset.neon | 25 ++ .../baselines/nullCoalesce.property.neon | 55 ++++ .../baselines/parameterByRef.unusedType.neon | 30 ++ .../baselines/property.onlyWritten.neon | 25 ++ .../baselines/property.unusedType.neon | 25 ++ .../phpstan/baselines/return.unusedType.neon | 100 ++++++ .../baselines/smallerOrEqual.alwaysTrue.neon | 25 ++ .../baselines/ternary.alwaysFalse.neon | 25 ++ .../phpstan/baselines/ternary.alwaysTrue.neon | 30 ++ .../phpstan/baselines/while.alwaysFalse.neon | 25 ++ tests/phpstan/baselines/while.alwaysTrue.neon | 300 +++++++++++++++++ 39 files changed, 2354 insertions(+), 1 deletion(-) create mode 100644 tests/phpstan/baselines/booleanAnd.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/booleanAnd.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon create mode 100644 tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon create mode 100644 tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon create mode 100644 tests/phpstan/baselines/booleanNot.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/booleanNot.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/booleanOr.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/booleanOr.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon create mode 100644 tests/phpstan/baselines/catch.neverThrown.neon create mode 100644 tests/phpstan/baselines/deadCode.unreachable.neon create mode 100644 tests/phpstan/baselines/empty.offset.neon create mode 100644 tests/phpstan/baselines/empty.property.neon create mode 100644 tests/phpstan/baselines/function.alreadyNarrowedType.neon create mode 100644 tests/phpstan/baselines/function.impossibleType.neon create mode 100644 tests/phpstan/baselines/function.resultUnused.neon create mode 100644 tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/identical.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/identical.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/if.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/if.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/instanceof.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/isset.offset.neon create mode 100644 tests/phpstan/baselines/isset.property.neon create mode 100644 tests/phpstan/baselines/method.unused.neon create mode 100644 tests/phpstan/baselines/notIdentical.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/nullCoalesce.offset.neon create mode 100644 tests/phpstan/baselines/nullCoalesce.property.neon create mode 100644 tests/phpstan/baselines/parameterByRef.unusedType.neon create mode 100644 tests/phpstan/baselines/property.onlyWritten.neon create mode 100644 tests/phpstan/baselines/property.unusedType.neon create mode 100644 tests/phpstan/baselines/return.unusedType.neon create mode 100644 tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/ternary.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/ternary.alwaysTrue.neon create mode 100644 tests/phpstan/baselines/while.alwaysFalse.neon create mode 100644 tests/phpstan/baselines/while.alwaysTrue.neon diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 43e42c278a7ce..d6c66d62e9bbe 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -25,16 +25,45 @@ includes: - tests/phpstan/baselines/arguments.count.neon - tests/phpstan/baselines/assign.propertyType.neon - tests/phpstan/baselines/binaryOp.invalid.neon + - tests/phpstan/baselines/booleanAnd.alwaysFalse.neon + - tests/phpstan/baselines/booleanAnd.alwaysTrue.neon + - tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon + - tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon + - tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon + - tests/phpstan/baselines/booleanNot.alwaysFalse.neon + - tests/phpstan/baselines/booleanNot.alwaysTrue.neon + - tests/phpstan/baselines/booleanOr.alwaysFalse.neon + - tests/phpstan/baselines/booleanOr.alwaysTrue.neon + - tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon + - tests/phpstan/baselines/catch.neverThrown.neon - tests/phpstan/baselines/class.nameCase.neon - tests/phpstan/baselines/class.notFound.neon + - tests/phpstan/baselines/deadCode.unreachable.neon + - tests/phpstan/baselines/empty.offset.neon + - tests/phpstan/baselines/empty.property.neon - tests/phpstan/baselines/empty.variable.neon - tests/phpstan/baselines/encapsedStringPart.nonString.neon - tests/phpstan/baselines/foreach.nonIterable.neon + - tests/phpstan/baselines/function.alreadyNarrowedType.neon + - tests/phpstan/baselines/function.impossibleType.neon + - tests/phpstan/baselines/function.resultUnused.neon - tests/phpstan/baselines/greater.invalid.neon + - tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon + - tests/phpstan/baselines/identical.alwaysFalse.neon + - tests/phpstan/baselines/identical.alwaysTrue.neon + - tests/phpstan/baselines/if.alwaysFalse.neon + - tests/phpstan/baselines/if.alwaysTrue.neon + - tests/phpstan/baselines/instanceof.alwaysTrue.neon + - tests/phpstan/baselines/isset.offset.neon + - tests/phpstan/baselines/isset.property.neon - tests/phpstan/baselines/isset.variable.neon - tests/phpstan/baselines/method.childParameterType.neon - tests/phpstan/baselines/method.nonObject.neon - tests/phpstan/baselines/method.notFound.neon + - tests/phpstan/baselines/method.unused.neon + - tests/phpstan/baselines/notIdentical.alwaysTrue.neon + - tests/phpstan/baselines/nullCoalesce.offset.neon + - tests/phpstan/baselines/nullCoalesce.property.neon - tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon - tests/phpstan/baselines/offsetAccess.notFound.neon - tests/phpstan/baselines/offsetAssign.valueType.neon @@ -43,23 +72,32 @@ includes: - tests/phpstan/baselines/parameter.phpDocType.neon - tests/phpstan/baselines/parameter.unresolvableType.neon - tests/phpstan/baselines/parameterByRef.type.neon + - tests/phpstan/baselines/parameterByRef.unusedType.neon - tests/phpstan/baselines/property.defaultValue.neon - tests/phpstan/baselines/property.nonObject.neon - tests/phpstan/baselines/property.notFound.neon + - tests/phpstan/baselines/property.onlyWritten.neon - tests/phpstan/baselines/property.phpDocType.neon - tests/phpstan/baselines/property.private.neon - tests/phpstan/baselines/property.protected.neon + - tests/phpstan/baselines/property.unusedType.neon - tests/phpstan/baselines/return.empty.neon - tests/phpstan/baselines/return.missing.neon - tests/phpstan/baselines/return.type.neon + - tests/phpstan/baselines/return.unusedType.neon + - tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon - tests/phpstan/baselines/staticClassAccess.privateMethod.neon + - tests/phpstan/baselines/ternary.alwaysFalse.neon + - tests/phpstan/baselines/ternary.alwaysTrue.neon - tests/phpstan/baselines/varTag.noVariable.neon - tests/phpstan/baselines/variable.undefined.neon + - tests/phpstan/baselines/while.alwaysFalse.neon + - tests/phpstan/baselines/while.alwaysTrue.neon # phpstan:baselines end parameters: # https://phpstan.org/user-guide/rule-levels - level: 3 + level: 4 reportUnmatchedIgnoredErrors: true # The following ignored errors are not intended to be fixed, as distinct from the baselines diff --git a/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon b/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon new file mode 100644 index 0000000000000..4bbff3cdb566b --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.alwaysFalse.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `booleanAnd.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php diff --git a/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon new file mode 100644 index 0000000000000..52bae4992b186 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanAnd.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of && is always true\.$#' + identifier: booleanAnd.alwaysTrue + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon new file mode 100644 index 0000000000000..c64a48177e085 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.leftAlwaysTrue.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `booleanAnd.leftAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.leftAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.leftAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-admin/network/users.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-admin/themes.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/block-template-utils.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon b/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon new file mode 100644 index 0000000000000..367c8dd35051c --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.rightAlwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanAnd.rightAlwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.rightAlwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.rightAlwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of && is always false\.$#' + identifier: booleanAnd.rightAlwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wpdb.php diff --git a/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon b/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon new file mode 100644 index 0000000000000..64071efcf3d23 --- /dev/null +++ b/tests/phpstan/baselines/booleanAnd.rightAlwaysTrue.neon @@ -0,0 +1,65 @@ +# PHPStan baseline for the `booleanAnd.rightAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanAnd.rightAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanAnd.rightAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/schema.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/header.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/typography.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-walker.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 3 + path: ../../../src/wp-includes/l10n.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 4 + path: ../../../src/wp-includes/load.php + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 2 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/booleanNot.alwaysFalse.neon b/tests/phpstan/baselines/booleanNot.alwaysFalse.neon new file mode 100644 index 0000000000000..57e8a715cfc8d --- /dev/null +++ b/tests/phpstan/baselines/booleanNot.alwaysFalse.neon @@ -0,0 +1,50 @@ +# PHPStan baseline for the `booleanNot.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanNot.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanNot.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/includes/theme.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/link-manager.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 2 + path: ../../../src/wp-admin/network/users.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: ../../../src/wp-includes/nav-menu.php diff --git a/tests/phpstan/baselines/booleanNot.alwaysTrue.neon b/tests/phpstan/baselines/booleanNot.alwaysTrue.neon new file mode 100644 index 0000000000000..ae0b0afe52c56 --- /dev/null +++ b/tests/phpstan/baselines/booleanNot.alwaysTrue.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `booleanNot.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanNot.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanNot.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-upgrader.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block-templates-registry.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: ../../../src/wp-includes/option.php diff --git a/tests/phpstan/baselines/booleanOr.alwaysFalse.neon b/tests/phpstan/baselines/booleanOr.alwaysFalse.neon new file mode 100644 index 0000000000000..86e1740697619 --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanOr.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of \|\| is always false\.$#' + identifier: booleanOr.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php diff --git a/tests/phpstan/baselines/booleanOr.alwaysTrue.neon b/tests/phpstan/baselines/booleanOr.alwaysTrue.neon new file mode 100644 index 0000000000000..6ee9845afe5be --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.alwaysTrue.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `booleanOr.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Result of \|\| is always true\.$#' + identifier: booleanOr.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/position.php + - + message: '#^Result of \|\| is always true\.$#' + identifier: booleanOr.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-block.php diff --git a/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon b/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon new file mode 100644 index 0000000000000..3234f5ad209b0 --- /dev/null +++ b/tests/phpstan/baselines/booleanOr.rightAlwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `booleanOr.rightAlwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/booleanOr.rightAlwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=booleanOr.rightAlwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Right side of \|\| is always true\.$#' + identifier: booleanOr.rightAlwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php diff --git a/tests/phpstan/baselines/catch.neverThrown.neon b/tests/phpstan/baselines/catch.neverThrown.neon new file mode 100644 index 0000000000000..30c8a4a7cfe4b --- /dev/null +++ b/tests/phpstan/baselines/catch.neverThrown.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `catch.neverThrown` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/catch.neverThrown +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=catch.neverThrown +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Dead catch \- Exception is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/deadCode.unreachable.neon b/tests/phpstan/baselines/deadCode.unreachable.neon new file mode 100644 index 0000000000000..f8a2c6671a783 --- /dev/null +++ b/tests/phpstan/baselines/deadCode.unreachable.neon @@ -0,0 +1,305 @@ +# PHPStan baseline for the `deadCode.unreachable` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/deadCode.unreachable +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=deadCode.unreachable +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/about.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/credits.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 3 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: ../../../src/wp-admin/post.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/taxonomy-post_format.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/taxonomy-post_format.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/category.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/tag.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/archive.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/index.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/search.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/class-wp-block.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 31 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: ../../../src/wp-includes/sitemaps/class-wp-sitemaps.php diff --git a/tests/phpstan/baselines/empty.offset.neon b/tests/phpstan/baselines/empty.offset.neon new file mode 100644 index 0000000000000..ac1410fad0869 --- /dev/null +++ b/tests/phpstan/baselines/empty.offset.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `empty.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/empty.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=empty.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset mixed on array\{\} in empty\(\) does not exist\.$#' + identifier: empty.offset + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Offset ''created_timestamp'' on array\{\}\|array\{lossless\?\: mixed, bitrate\?\: int, bitrate_mode\?\: mixed, filesize\?\: int, mime_type\?\: mixed, length\?\: int, length_formatted\?\: mixed, width\?\: int, \.\.\.\} in empty\(\) does not exist\.$#' + identifier: empty.offset + count: 1 + path: ../../../src/wp-admin/includes/media.php diff --git a/tests/phpstan/baselines/empty.property.neon b/tests/phpstan/baselines/empty.property.neon new file mode 100644 index 0000000000000..5bc6d4e3cbdd3 --- /dev/null +++ b/tests/phpstan/baselines/empty.property.neon @@ -0,0 +1,65 @@ +# PHPStan baseline for the `empty.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/empty.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=empty.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Block_Type\:\:\$render_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Property WP_Customize_Control\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 4 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$widgets \(WP_Customize_Widgets\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Panel\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-panel.php + - + message: '#^Property WP_Customize_Section\:\:\$active_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-section.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php + - + message: '#^Property WP_Customize_Manager\:\:\$nav_menus \(WP_Customize_Nav_Menus\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-nav-menu-setting.php + - + message: '#^Property WP_Customize_Partial\:\:\$render_callback \(callable\) in empty\(\) is not falsy\.$#' + identifier: empty.property + count: 2 + path: ../../../src/wp-includes/customize/class-wp-customize-partial.php diff --git a/tests/phpstan/baselines/function.alreadyNarrowedType.neon b/tests/phpstan/baselines/function.alreadyNarrowedType.neon new file mode 100644 index 0000000000000..5d0293fde3b5b --- /dev/null +++ b/tests/phpstan/baselines/function.alreadyNarrowedType.neon @@ -0,0 +1,105 @@ +# PHPStan baseline for the `function.alreadyNarrowedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.alreadyNarrowedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.alreadyNarrowedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Call to function is_wp_error\(\) with WP_Error will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Call to function method_exists\(\) with ''ParagonIE_Sodium…'' and ''runtime_speed_test'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Call to function is_callable\(\) with ''exif_read_data'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to function is_callable\(\) with ''iptcparse'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/image.php + - + message: '#^Call to function is_numeric\(\) with float\|int\|numeric\-string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/block-editor.php + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-block-bindings-registry.php + - + message: '#^Call to function method_exists\(\) with ''Imagick'' and ''setIteratorIndex'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Call to function is_callable\(\) with ''exif_read_data'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor.php + - + message: '#^Call to function method_exists\(\) with ''SimplePie_Cache'' and ''register'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Call to function is_callable\(\) with ''exif_imagetype'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/interactivity-api/class-wp-interactivity-api.php + - + message: '#^Call to function is_string\(\) with non\-falsy\-string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Call to function wp_die\(\) with arguments non\-falsy\-string, mixed and array\{exit\: false, code\: ''mysql_not_found''\} will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function is_array\(\) with non\-empty\-array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Call to function is_array\(\) with array\{non\-falsy\-string, non\-falsy\-string&numeric\-string, numeric\-string, numeric\-string\} will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../../src/wp-includes/post.php diff --git a/tests/phpstan/baselines/function.impossibleType.neon b/tests/phpstan/baselines/function.impossibleType.neon new file mode 100644 index 0000000000000..b096e7ff399bd --- /dev/null +++ b/tests/phpstan/baselines/function.impossibleType.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `function.impossibleType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.impossibleType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.impossibleType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function is_string\(\) with bool will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php + - + message: '#^Call to function is_wp_error\(\) with 0\|0\.0\|''''\|''0''\|array\{\}\|false\|null will always evaluate to false\.$#' + identifier: function.impossibleType + count: 2 + path: ../../../src/wp-admin/update.php + - + message: '#^Call to function is_wp_error\(\) with array will always evaluate to false\.$#' + identifier: function.impossibleType + count: 2 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Call to function is_string\(\) with bool will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: ../../../src/wp-includes/load.php diff --git a/tests/phpstan/baselines/function.resultUnused.neon b/tests/phpstan/baselines/function.resultUnused.neon new file mode 100644 index 0000000000000..1ad6a84dbfa4c --- /dev/null +++ b/tests/phpstan/baselines/function.resultUnused.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `function.resultUnused` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/function.resultUnused +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=function.resultUnused +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function wp_cache_close\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Call to function wp_cache_add_non_persistent_groups\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: ../../../src/wp-includes/ms-blogs.php diff --git a/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon b/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon new file mode 100644 index 0000000000000..9b0969a81be78 --- /dev/null +++ b/tests/phpstan/baselines/greaterOrEqual.alwaysTrue.neon @@ -0,0 +1,60 @@ +# PHPStan baseline for the `greaterOrEqual.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/greaterOrEqual.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=greaterOrEqual.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\>\=" between int\<70400, 80500\> and 70300 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-utils.php + - + message: '#^Comparison operation "\>\=" between int\<70400, 80500\> and 70400 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/fonts/class-wp-font-utils.php + - + message: '#^Comparison operation "\>\=" between int\<2592000, 31535999\> and 2592000 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<31536000, max\> and 31536000 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<3600, 86399\> and 3600 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<60, 3599\> and 60 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<604800, 2591999\> and 604800 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Comparison operation "\>\=" between int\<86400, 604799\> and 86400 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/formatting.php diff --git a/tests/phpstan/baselines/identical.alwaysFalse.neon b/tests/phpstan/baselines/identical.alwaysFalse.neon new file mode 100644 index 0000000000000..47e10c0e67b9a --- /dev/null +++ b/tests/phpstan/baselines/identical.alwaysFalse.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `identical.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/identical.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=identical.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \=\=\= between ''update\-selected'' and mixed~\(''activate''\|''activate\-selected''\|''deactivate''\|''deactivate\-selected''\|''delete\-selected''\|''disable\-auto\-update''\|''disable\-auto\-update\-selected''\|''enable\-auto\-update''\|''enable\-auto\-update\-selected''\|''error_scrape''\|''resume''\|''update\-selected''\) will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Strict comparison using \=\=\= between ''exceeded\-max…'' and null will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Strict comparison using \=\=\= between ''STATE_INCOMPLETE…'' and ''STATE_READY'' will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \=\=\= between 3000000000 and 2147483647 will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Strict comparison using \=\=\= between false and mixed will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php diff --git a/tests/phpstan/baselines/identical.alwaysTrue.neon b/tests/phpstan/baselines/identical.alwaysTrue.neon new file mode 100644 index 0000000000000..59b590acc867e --- /dev/null +++ b/tests/phpstan/baselines/identical.alwaysTrue.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `identical.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/identical.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=identical.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \=\=\= between ''themezip'' and ''themezip'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-file-upload-upgrader.php + - + message: '#^Strict comparison using \=\=\= between ''sticky'' and ''sticky'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/position.php + - + message: '#^Strict comparison using \=\=\= between ''404'' and ''404'' will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp.php + - + message: '#^Strict comparison using \=\=\= between true and true will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/rest-api.php diff --git a/tests/phpstan/baselines/if.alwaysFalse.neon b/tests/phpstan/baselines/if.alwaysFalse.neon new file mode 100644 index 0000000000000..d0346f40c526c --- /dev/null +++ b/tests/phpstan/baselines/if.alwaysFalse.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `if.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/if.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=if.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 2 + path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-includes/template.php + - + message: '#^If condition is always false\.$#' + identifier: if.alwaysFalse + count: 1 + path: ../../../src/wp-login.php diff --git a/tests/phpstan/baselines/if.alwaysTrue.neon b/tests/phpstan/baselines/if.alwaysTrue.neon new file mode 100644 index 0000000000000..f049efde2d19d --- /dev/null +++ b/tests/phpstan/baselines/if.alwaysTrue.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `if.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/if.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=if.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-admin/my-sites.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 2 + path: ../../../src/wp-admin/upload.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/comments.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/template-parts/footer/footer-widgets.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/template-parts/modal-menu.php + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php diff --git a/tests/phpstan/baselines/instanceof.alwaysTrue.neon b/tests/phpstan/baselines/instanceof.alwaysTrue.neon new file mode 100644 index 0000000000000..087710cdf012e --- /dev/null +++ b/tests/phpstan/baselines/instanceof.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `instanceof.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/instanceof.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=instanceof.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Instanceof between Imagick and Imagick will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php diff --git a/tests/phpstan/baselines/isset.offset.neon b/tests/phpstan/baselines/isset.offset.neon new file mode 100644 index 0000000000000..1db99c69fbf03 --- /dev/null +++ b/tests/phpstan/baselines/isset.offset.neon @@ -0,0 +1,40 @@ +# PHPStan baseline for the `isset.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/isset.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=isset.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset \(float\|int\) on non\-empty\-array\ in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Offset int\<1, max\> on non\-empty\-list\ in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Offset 2 on array\{string, non\-empty\-string, non\-empty\-string\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Offset ''orderby'' on array\{post_parent\: mixed, post_type\: ''revision'', post_status\: ''inherit'', posts_per_page\: mixed, orderby\: mixed, order\: mixed, suppress_filters\: true, post__not_in\?\: mixed, \.\.\.\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php diff --git a/tests/phpstan/baselines/isset.property.neon b/tests/phpstan/baselines/isset.property.neon new file mode 100644 index 0000000000000..2a0c8971ceb55 --- /dev/null +++ b/tests/phpstan/baselines/isset.property.neon @@ -0,0 +1,220 @@ +# PHPStan baseline for the `isset.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/isset.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=isset.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_Post\:\:\$post_type \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 3 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-checklist.php + - + message: '#^Property WP_Post\:\:\$post_status \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Property WP_Post\:\:\$post_title \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^Property WP_Screen\:\:\$post_type \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Property WP_Taxonomy\:\:\$meta_box_sanitize_cb \(callable\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Property WP_Site\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-admin/my-sites.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/inc/customizer.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/functions.php + - + message: '#^Property WP_Customize_Manager\:\:\$selective_refresh \(WP_Customize_Selective_Refresh\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Property WP_Block_Type\:\:\$editor_style_handles \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/block-editor.php + - + message: '#^Property WP_Block_Type\:\:\$selectors \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/block-supports/states.php + - + message: '#^Property WP_Customize_Control\:\:\$settings \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Property WP_Customize_Manager\:\:\$_changeset_post_id \(int\|false\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$_changeset_uuid \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Manager\:\:\$_post_values \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Property WP_Customize_Setting\:\:\$_previewed_blog_id \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 2 + path: ../../../src/wp-includes/class-wp-customize-setting.php + - + message: '#^Property WP_Customize_Widgets\:\:\$selective_refreshable_widgets \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-customize-widgets.php + - + message: '#^Property WP_Http_Cookie\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$value \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Post\:\:\$ID \(int\<0, max\>\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Static property WP_Theme\:\:\$persistently_cache \(bool\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Static property WP_User\:\:\$back_compat_keys \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Property WP_Widget\:\:\$alt_option_name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wp-widget.php + - + message: '#^Property wpdb\:\:\$base_prefix \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Property WP_Customize_Partial\:\:\$settings \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-partial.php + - + message: '#^Property WP_HTML_Text_Replacement\:\:\$text \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 2 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Property WP_Post\:\:\$post_status \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Property WP_Object_Cache\:\:\$cache \(array\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Property WP_Object_Cache\:\:\$global_groups \(array\\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/ms-blogs.php + - + message: '#^Property WP_Post\:\:\$ID \(int\<0, max\>\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Property WP_Term\:\:\$term_id \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Property WP_Post\:\:\$post_name \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php + - + message: '#^Property WP_Post\:\:\$post_title \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php + - + message: '#^Property WP_Query\:\:\$max_num_pages \(int\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php + - + message: '#^Property WP_Site\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' + identifier: isset.property + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/method.unused.neon b/tests/phpstan/baselines/method.unused.neon new file mode 100644 index 0000000000000..f4314a8b42a5c --- /dev/null +++ b/tests/phpstan/baselines/method.unused.neon @@ -0,0 +1,45 @@ +# PHPStan baseline for the `method.unused` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/method.unused +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=method.unused +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Static method WP_Internal_Pointers\:\:print_js\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-admin/includes/class-wp-internal-pointers.php + - + message: '#^Method WP_Http\:\:_dispatch_request\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/class-wp-http.php + - + message: '#^Method WP_Script_Modules\:\:get_marked_for_enqueue\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/class-wp-script-modules.php + - + message: '#^Method WP_HTML_Tag_Processor\:\:skip_rawtext\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Method WP_HTML_Tag_Processor\:\:skip_script_data\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/notIdentical.alwaysTrue.neon b/tests/phpstan/baselines/notIdentical.alwaysTrue.neon new file mode 100644 index 0000000000000..5fed187271bfc --- /dev/null +++ b/tests/phpstan/baselines/notIdentical.alwaysTrue.neon @@ -0,0 +1,75 @@ +# PHPStan baseline for the `notIdentical.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/notIdentical.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=notIdentical.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Strict comparison using \!\=\= between ''all'' and int will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/class-wp-links-list-table.php + - + message: '#^Strict comparison using \!\=\= between null and string will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-supports/layout.php + - + message: '#^Strict comparison using \!\=\= between null and int\|string will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 2 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Strict comparison using \!\=\= between array\{\} and non\-empty\-array\ will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-view-config-data.php + - + message: '#^Strict comparison using \!\=\= between ''Etc'' and ''Africa''\|''America''\|''Antarctica''\|''Arctic''\|''Asia''\|''Atlantic''\|''Australia''\|''Europe''\|''Indian''\|''Pacific'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_COMPLETE'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_INCOMPLETE…'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between ''STATE_MATCHED_TAG'' and ''STATE_READY'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Strict comparison using \!\=\= between 0 and int\\|int\<1, max\> will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Strict comparison using \!\=\= between float\|int\|numeric\-string and ''bottom''\|''footer''\|''header''\|''main''\|''menu\-1''\|''menu\-2''\|''navigation''\|''primary''\|''secondary''\|''social''\|''subsidiary''\|''top'' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 2 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Strict comparison using \!\=\= between false and int will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../../src/wp-includes/pluggable.php diff --git a/tests/phpstan/baselines/nullCoalesce.offset.neon b/tests/phpstan/baselines/nullCoalesce.offset.neon new file mode 100644 index 0000000000000..cdf94447df8f3 --- /dev/null +++ b/tests/phpstan/baselines/nullCoalesce.offset.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `nullCoalesce.offset` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/nullCoalesce.offset +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=nullCoalesce.offset +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Offset 1 on array\{list\, list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: ../../../src/wp-includes/block-supports/block-style-variations.php diff --git a/tests/phpstan/baselines/nullCoalesce.property.neon b/tests/phpstan/baselines/nullCoalesce.property.neon new file mode 100644 index 0000000000000..3b30d530a3ab3 --- /dev/null +++ b/tests/phpstan/baselines/nullCoalesce.property.neon @@ -0,0 +1,55 @@ +# PHPStan baseline for the `nullCoalesce.property` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/nullCoalesce.property +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=nullCoalesce.property +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_User\:\:\$ID \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/author-template.php + - + message: '#^Property WP_Http_Cookie\:\:\$path \(string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Http_Cookie\:\:\$port \(int\|string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-http-cookie.php + - + message: '#^Property WP_Locale\:\:\$word_count_type \(string\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/class-wp-locale.php + - + message: '#^Property WP_Query\:\:\$max_num_pages \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Property WP_Post_Type\:\:\$template \(array\\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php + - + message: '#^Property WP_User\:\:\$ID \(int\) on left side of \?\? is not nullable\.$#' + identifier: nullCoalesce.property + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/parameterByRef.unusedType.neon b/tests/phpstan/baselines/parameterByRef.unusedType.neon new file mode 100644 index 0000000000000..73746ef20c078 --- /dev/null +++ b/tests/phpstan/baselines/parameterByRef.unusedType.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `parameterByRef.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/parameterByRef.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=parameterByRef.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function _wp_scan_utf8\(\) never assigns null to &\$has_noncharacters so it can be removed from the by\-ref type\.$#' + identifier: parameterByRef.unusedType + count: 1 + path: ../../../src/wp-includes/compat-utf8.php + - + message: '#^Function _wp_utf8_codepoint_span\(\) never assigns null to &\$found_code_points so it can be removed from the by\-ref type\.$#' + identifier: parameterByRef.unusedType + count: 1 + path: ../../../src/wp-includes/compat-utf8.php diff --git a/tests/phpstan/baselines/property.onlyWritten.neon b/tests/phpstan/baselines/property.onlyWritten.neon new file mode 100644 index 0000000000000..f4b740e1bdf02 --- /dev/null +++ b/tests/phpstan/baselines/property.onlyWritten.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `property.onlyWritten` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.onlyWritten +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.onlyWritten +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_REST_Template_Autosaves_Controller\:\:\$parent_post_type is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php diff --git a/tests/phpstan/baselines/property.unusedType.neon b/tests/phpstan/baselines/property.unusedType.neon new file mode 100644 index 0000000000000..9f1bd06e86c8f --- /dev/null +++ b/tests/phpstan/baselines/property.unusedType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `property.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/property.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=property.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Property WP_HTML_Tag_Processor\:\:\$skip_newline_at \(int\|null\) is never assigned int so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php diff --git a/tests/phpstan/baselines/return.unusedType.neon b/tests/phpstan/baselines/return.unusedType.neon new file mode 100644 index 0000000000000..2acebf529a781 --- /dev/null +++ b/tests/phpstan/baselines/return.unusedType.neon @@ -0,0 +1,100 @@ +# PHPStan baseline for the `return.unusedType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/return.unusedType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=return.unusedType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Function plugins_api\(\) never returns array so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Function _fix_attachment_links\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Function get_preferred_from_update_core\(\) never returns array so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-admin/includes/update.php + - + message: '#^Function get_the_tag_list\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Function get_the_tag_list\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category-template.php + - + message: '#^Function get_category\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/category.php + - + message: '#^Method WP_Recovery_Mode_Cookie_Service\:\:recovery_mode_hash\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/class-wp-recovery-mode-cookie-service.php + - + message: '#^Function wp_get_code_editor_settings\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Function get_post_gallery\(\) never returns string so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Function wp_imagecreatetruecolor\(\) never returns GdImage so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Function wp_mime_type_icon\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Function _set_preview\(\) never returns false so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/revision.php + - + message: '#^Function get_term_to_edit\(\) never returns int so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function get_term_to_edit\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Function wp_is_password_reset_allowed_for_user\(\) never returns WP_Error so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Function validate_another_blog_signup\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: ../../../src/wp-signup.php diff --git a/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon b/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon new file mode 100644 index 0000000000000..aed03b30ea48d --- /dev/null +++ b/tests/phpstan/baselines/smallerOrEqual.alwaysTrue.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `smallerOrEqual.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/smallerOrEqual.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=smallerOrEqual.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Comparison operation "\<\=" between 0 and int\<0, max\>\|false is always true\.$#' + identifier: smallerOrEqual.alwaysTrue + count: 1 + path: ../../../src/wp-includes/class-wp-theme-json.php diff --git a/tests/phpstan/baselines/ternary.alwaysFalse.neon b/tests/phpstan/baselines/ternary.alwaysFalse.neon new file mode 100644 index 0000000000000..b73c48bc42724 --- /dev/null +++ b/tests/phpstan/baselines/ternary.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `ternary.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/ternary.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=ternary.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Ternary operator condition is always false\.$#' + identifier: ternary.alwaysFalse + count: 2 + path: ../../../src/wp-admin/includes/class-wp-debug-data.php diff --git a/tests/phpstan/baselines/ternary.alwaysTrue.neon b/tests/phpstan/baselines/ternary.alwaysTrue.neon new file mode 100644 index 0000000000000..295254051f683 --- /dev/null +++ b/tests/phpstan/baselines/ternary.alwaysTrue.neon @@ -0,0 +1,30 @@ +# PHPStan baseline for the `ternary.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/ternary.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=ternary.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: ../../../src/wp-admin/menu-header.php + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: ../../../src/wp-admin/theme-install.php diff --git a/tests/phpstan/baselines/while.alwaysFalse.neon b/tests/phpstan/baselines/while.alwaysFalse.neon new file mode 100644 index 0000000000000..3c924003ea783 --- /dev/null +++ b/tests/phpstan/baselines/while.alwaysFalse.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `while.alwaysFalse` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/while.alwaysFalse +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=while.alwaysFalse +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^While loop condition is always false\.$#' + identifier: while.alwaysFalse + count: 1 + path: ../../../src/wp-includes/feed-rdf.php diff --git a/tests/phpstan/baselines/while.alwaysTrue.neon b/tests/phpstan/baselines/while.alwaysTrue.neon new file mode 100644 index 0000000000000..5da6550e89cc0 --- /dev/null +++ b/tests/phpstan/baselines/while.alwaysTrue.neon @@ -0,0 +1,300 @@ +# PHPStan baseline for the `while.alwaysTrue` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/while.alwaysTrue +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=while.alwaysTrue +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/taxonomy-post_format.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/front-page.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-page.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-single.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/taxonomy-post_format.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/category.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/tag.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/singular.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/templates/template-cover.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/archive.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/index.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/search.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-includes/block-template.php + - + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue + count: 1 + path: ../../../src/wp-includes/theme-compat/embed.php From b00336ac8f018202db0b5a6760cbf2b87a7b34f1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 5 Aug 2026 08:37:55 +0000 Subject: [PATCH 326/336] Build/Test Tools: Raise the PHPStan rule level to 5. This rule level includes: > checking types of arguments passed to methods and functions Baselines are regenerated for errors at this level. Developed in https://github.com/WordPress/wordpress-develop/pull/12855. Follow-up to r61699, r63019, r63020, r63021, r63022, r63023. Props westonruter, apermo. See #64680. git-svn-id: https://develop.svn.wordpress.org/trunk@63024 602fd350-edb4-49c9-b593-d223f7449a82 --- phpstan.neon.dist | 5 +- tests/phpstan/baselines/argument.type.neon | 1885 +++++++++++++++++ .../baselines/argument.unresolvableType.neon | 25 + tests/phpstan/baselines/arrayValues.list.neon | 25 + 4 files changed, 1939 insertions(+), 1 deletion(-) create mode 100644 tests/phpstan/baselines/argument.type.neon create mode 100644 tests/phpstan/baselines/argument.unresolvableType.neon create mode 100644 tests/phpstan/baselines/arrayValues.list.neon diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d6c66d62e9bbe..3dcf0f6c2c0de 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,7 +22,10 @@ includes: # Regenerate with `composer phpstan:baselines`, which rewrites both the files # and the list between the markers. Do not edit that list by hand. # phpstan:baselines start + - tests/phpstan/baselines/argument.type.neon + - tests/phpstan/baselines/argument.unresolvableType.neon - tests/phpstan/baselines/arguments.count.neon + - tests/phpstan/baselines/arrayValues.list.neon - tests/phpstan/baselines/assign.propertyType.neon - tests/phpstan/baselines/binaryOp.invalid.neon - tests/phpstan/baselines/booleanAnd.alwaysFalse.neon @@ -97,7 +100,7 @@ includes: parameters: # https://phpstan.org/user-guide/rule-levels - level: 4 + level: 5 reportUnmatchedIgnoredErrors: true # The following ignored errors are not intended to be fixed, as distinct from the baselines diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon new file mode 100644 index 0000000000000..4415f515ba9e6 --- /dev/null +++ b/tests/phpstan/baselines/argument.type.neon @@ -0,0 +1,1885 @@ +# PHPStan baseline for the `argument.type` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/argument.type +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=argument.type +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$key of function remove_query_arg expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-activate.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/admin-header.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$post of function get_post_status expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/comment.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/customize.php + - + message: '#^Parameter \#1 \$position of function wp_comment_reply expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-comments.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-advanced.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-form-comment.php + - + message: '#^Parameter \#1 \$screen of function do_meta_boxes expects string\|WP_Screen, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/edit-link-form.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit-tag-form.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$post of function get_post_type expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$attachment of function wp_get_attachment_id3_keys expects WP_Post, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$comment_id of function _wp_ajax_delete_comment_response expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#2 \$compare_from of function wp_get_revision_ui_diff expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#3 \$compare_to of function wp_get_revision_ui_diff expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#2 \$gmt of function current_time expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/bookmark.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-automatic-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-bulk-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function esc_js expects string, int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/class-bulk-upgrader-skin.php + - + message: '#^Parameter \#1 \$text of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-custom-background.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, float\|int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$text of function submit_button expects string, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-custom-image-header.php + - + message: '#^Parameter \#1 \$language_updates of method Language_Pack_Upgrader\:\:bulk_upgrade\(\) expects array\, list\\|string\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-language-pack-upgrader.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-walker-nav-menu-edit.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#3 \$post of function get_comment_class expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-users-list-table.php + - + message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-plugins-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-export-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-data-removal-requests-list-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/class-wp-privacy-requests-table.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-screen.php + - + message: '#^Parameter \#1 \$version of function get_core_checksums expects string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health-auto-updates.php + - + message: '#^Parameter \#1 \$bytes of function size_format expects int\|string, float\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Parameter \#1 \$args of function WP_Filesystem expects array\|false, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-upgrader.php + - + message: '#^Parameter \#1 \$post of function _draft_or_post_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$post of function get_the_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, false given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(stdClass\)\: mixed\)\|null, ''get_comment'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#1 \$term of function get_term expects int\|object, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#2 \$callback of function add_filter expects callable\(\)\: mixed, ''wxr_filter_postmeta'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/file.php + - + message: '#^Parameter \#1 \$image of function is_gd_image expects GdImage\|resource\|false, WP_Image_Editor given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$width of function wp_imagecreatetruecolor expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#2 \$height of function wp_imagecreatetruecolor expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#5 \$src_x of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#6 \$src_y of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#7 \$src_w of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#8 \$src_h of function imagecopy expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/image-edit.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_attachment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/import.php + - + message: '#^Parameter \#1 \$number of function number_format_i18n expects float, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#1 \$post_id of function get_media_items expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Parameter \#2 \$result of function wp_parse_str expects array, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/menu.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/meta-boxes.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 8 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/misc.php + - + message: '#^Parameter \#1 \$link_id of function wp_delete_link expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#3 \$value of function update_blog_status expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ms.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#2 \$arr2 of function array_intersect expects an array of values castable to string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#7 \$callback_args of function add_meta_box expects array\|null, WP_Post_Type given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/nav-menu.php + - + message: '#^Parameter \#1 \$tags of function wp_generate_tag_cloud expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#3 \$name of function submit_button expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/plugin-install.php + - + message: '#^Parameter \#2 \$allowed_html of function wp_kses expects array\\|string, array\\|true\> given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/includes/plugin.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/includes/post.php + - + message: '#^Parameter \#1 \$user_id of function switch_to_user_locale expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/privacy-tools.php + - + message: '#^Parameter \#2 \$user_id of function get_the_author_meta expects int\|false, ''''\|numeric\-string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/revision.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/schema.php + - + message: '#^Parameter \#2 \$multiplier of function str_repeat expects int, float given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#2 \$title of function add_meta_box expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#3 \$callback of function add_meta_box expects callable\(\)\: mixed, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/template.php + - + message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/translation-install.php + - + message: '#^Parameter \#3 \$overwrite of method WP_Filesystem_Base\:\:copy\(\) expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/update-core.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/update.php + - + message: '#^Parameter \#1 \$timestamp of function wp_schedule_event expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Parameter \#3 \$deprecated of function add_option expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Parameter \#1 \$bookmark_id of function clean_bookmark_cache expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$link_id of function wp_delete_link expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$post of function clean_post_cache expects int\|WP_Post, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#1 \$user of function wp_get_user_contact_methods expects WP_User\|null, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Parameter \#4 \$is_public of function wp_install expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/install.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 5 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Parameter \#2 \$menu_data of function wp_save_nav_menu_items expects array\, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/nav-menus.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-info.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-info.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-settings.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-settings.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-themes.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/network/site-themes.php + - + message: '#^Parameter \#1 \$network_id of function can_edit_network expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/network/site-users.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-admin/network/site-users.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-admin/network/sites.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-discussion.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-general.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, 6\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/options-general.php + - + message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(mixed\)\: bool\)\|null, ''validate_file'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/plugins.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/update.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/user-edit.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 10 + path: ../../../src/wp-admin/users.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/content-image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentyeleven/content-single.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/image.php + - + message: '#^Parameter \#1 \$screen of function add_contextual_help expects string, WP_Screen\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#3 \$args of function register_setting expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/inc/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Parameter \#2 \$instance of function the_widget expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyeleven/showcase.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/author-bio.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfifteen/inc/template-tags.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentyfourteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyfourteen/inc/widgets.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/functions.php + - + message: '#^Parameter \#1 \$num of function dechex expects int, float given\.$#' + identifier: argument.type + count: 6 + path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/template-parts/post/author-bio.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/color-patterns.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/inc/template-tags.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, \(float\|int\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentysixteen/template-parts/biography.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$wp_head_callback of function add_custom_image_header expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-attachment.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentyten/loop-single.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author-bio.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentythirteen/image.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/author.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/content.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/functions.php + - + message: '#^Parameter \#1 \$size of function next_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/image.php + - + message: '#^Parameter \#1 \$size of function previous_image_link expects array\\|string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwelve/image.php + - + message: '#^Parameter \#4 \$prefix of function twentytwenty_generate_css expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/classes/class-twentytwenty-non-latin-languages.php + - + message: '#^Parameter \#5 \$suffix of function twentytwenty_generate_css expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/classes/class-twentytwenty-non-latin-languages.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#3 \$deps of function wp_enqueue_style expects array\, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/template-parts/entry-author-bio.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\|false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/template-functions.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/inc/template-tags.php + - + message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/template-parts/post/author-bio.php + - + message: '#^Parameter \#2 \$size of function get_avatar expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/template-parts/post/author-bio.php + - + message: '#^Parameter \#1 \$userid of function count_user_posts expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/author-template.php + - + message: '#^Parameter \#1 \$separator of function explode expects string, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/block-supports/layout.php + - + message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Parameter \#4 \$block_context of function filter_block_kses_value expects array\|null, WP_Block_Parser_Block given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/blocks.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''lowercase_octets'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/canonical.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/capabilities.php + - + message: '#^Parameter \#1 \$name of class WP_Block_Parser_Block constructor expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-block-parser.php + - + message: '#^Parameter \#1 \$child_id of method WP_Comment\:\:get_child\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Parameter \#1 \$ids of function _prime_post_caches expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-control.php + - + message: '#^Parameter \#1 \$ajax_message of method WP_Customize_Manager\:\:wp_die\(\) expects string\|WP_Error, int given\.$#' + identifier: argument.type + count: 6 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$parent_query of method WP_Date_Query\:\:get_sql_for_clause\(\) expects array, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$timestamp of function gmdate expects int, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$value of method WP_Date_Query\:\:build_value\(\) expects array\|string, int given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#2 \$value of method WP_Date_Query\:\:build_value\(\) expects array\|string, int\|null given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Parameter \#1 \$value of static method WP_Duotone\:\:colord_parse_hue\(\) expects float, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-duotone.php + - + message: '#^Parameter \#3 \$priority of function _wp_filter_build_unique_id expects int, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-hook.php + - + message: '#^Parameter \#3 \$value of function curl_setopt expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-http-curl.php + - + message: '#^Parameter \#2 \$mode of function stream_set_blocking expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-http-streams.php + - + message: '#^Parameter \#2 \$callback of method WP_Image_Editor_GD\:\:make_image\(\) expects callable\(\)\: mixed, ''imageavif'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \#2 \$interlace of function imageinterlace expects int, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-gd.php + - + message: '#^Parameter \#2 \$limit of static method Imagick\:\:setResourceLimit\(\) expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#2 \$value of method Imagick\:\:setOption\(\) expects string, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-image-editor-imagick.php + - + message: '#^Parameter \#1 \$ids of function _prime_post_caches expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Parameter \#1 \$pages of function get_page_hierarchy expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-rewrite.php + - + message: '#^Parameter \#1 \$new_blog_id of function switch_to_blog expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-site.php + - + message: '#^Parameter \#1 \$metadata of method WP_Theme_JSON\:\:get_feature_declarations_for_node\(\) expects object, array given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#1 \$node of method WP_Theme_JSON\:\:process_pseudo_selectors\(\) expects array, object given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#1 \$styles of static method WP_Theme_JSON\:\:compute_style_properties\(\) expects array, object given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/class-wp-theme-json.php + - + message: '#^Parameter \#2 \$data of method WP_Theme\:\:cache_add\(\) expects array\|string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-theme.php + - + message: '#^Parameter \#1 \$str of function strtoupper expects string, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-token-map.php + - + message: '#^Parameter \#1 \$level of method WP_User\:\:translate_level_to_cap\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Parameter \#1 \$number of method WP_Widget\:\:_set\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-widget.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$term_id of method wp_xmlrpc_server\:\:get_term_custom_fields\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$comment_id of function get_page_of_comment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$post of function post_password_required expects int\|WP_Post\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment-template.php + - + message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function add_comment_meta expects int, string given\.$#' + identifier: argument.type + count: 5 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function delete_comment_meta expects int, string given\.$#' + identifier: argument.type + count: 8 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function get_comment_text expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comment_id of function get_page_of_comment expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$comments of function update_comment_cache expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$content of function pingback expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$ids of function clean_comment_cache expects array\|int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$post_id of function wp_update_comment_count expects int\|null, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#2 \$object_ids of function update_meta_cache expects array\\|string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Parameter \#1 \$gmt_time of function spawn_cron expects int, float given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/cron.php + - + message: '#^Parameter \#1 \$container_context of method WP_Customize_Partial\:\:render\(\) expects array, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/customize/class-wp-customize-selective-refresh.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/embed.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/embed.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/feed-atom-comments.php + - + message: '#^Parameter \#1 \$post_id of function get_post_comments_feed_link expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed-rss2.php + - + message: '#^Parameter \#1 \$post of function get_the_guid expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Parameter \#2 \$message of class WP_Error constructor expects string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/feed.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''_links_add_base'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Parameter \#2 \$callback of function preg_replace_callback expects callable\(array\\)\: string, ''_links_add_target'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/formatting.php + - + message: '#^Parameter \#1 \$prefix of function uniqid expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#1 \$weekday_number of method WP_Locale\:\:get_weekday\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#2 \$fallback_url of function wp_validate_redirect expects string, false given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#4 \$mon of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#5 \$day of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#6 \$year of function mktime expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$string of function substr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$string of function substr expects string, int\\|int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, float given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\<1, max\> given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\\|int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#3 \$url of function wp_admin_css_color expects string, false given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/general-template.php + - + message: '#^Parameter \#1 \$name of method WP_HTML_Tag_Processor\:\:set_bookmark\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-processor.php + - + message: '#^Parameter \#4 \$length of function substr_compare expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/html-api/class-wp-html-tag-processor.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Parameter \#1 \$str of function md5 expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, int given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<1, 9\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media-template.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int\<1, 9\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media-template.php + - + message: '#^Parameter \#5 \$text of function wp_get_attachment_link expects string\|false, bool given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^Parameter \#2 \$callback of function array_walk expects callable\(non\-empty\-string\|null, int\<0, max\>\)\: mixed, ''clean_bookmark_cache'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#2 \$callback of function array_walk expects callable\(non\-empty\-string\|null, int\<0, max\>\)\: mixed, ''clean_post_cache'' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#3 \$value of function update_blog_status expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Parameter \#1 \$network_id of static method WP_Network\:\:get_instance\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-load.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#2 \$object_id of function delete_metadata expects int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, \(string\|false\) given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Parameter \#2 \$value of function setcookie expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/option.php + - + message: '#^Parameter \#1 \$engine of class Text_Diff constructor expects string, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$number of function number_format_i18n expects float, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$post of function get_edit_post_link expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$post of function get_permalink expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 4 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$user of function user_can expects int\|WP_User, string given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$user_id of function get_userdata expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#3 \$number of function _n expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$attachment of function is_attachment expects array\\|int\|string, WP_Post given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#1 \$url of function user_trailingslashit expects string, int\\|int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$fallback of function sanitize_html_class expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$replace of function str_replace expects array\\|string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#2 \$user_id of function get_the_author_meta expects int\|false, ''''\|numeric\-string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php + - + message: '#^Parameter \#1 \$comment_id of function wp_delete_comment expects int\|WP_Comment, string\|null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$month of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post of function clean_post_cache expects int\|WP_Post, stdClass given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post of function get_post expects int\|numeric\-string\|WP_Post\|null, stdClass given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$post_id of function wp_delete_post expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$posts of function update_post_cache expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$revision of function wp_delete_post_revision expects int\|WP_Post, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 3 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$day of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#2 \$object_id of function delete_metadata expects int, null given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#3 \$year of function wp_checkdate expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api.php + - + message: '#^Parameter \#1 \$haystack of function str_starts_with expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:envelope_response\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$response of method WP_REST_Server\:\:response_to_data\(\) expects WP_REST_Response, WP_HTTP_Response given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/class-wp-rest-server.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php + - + message: '#^Parameter \#1 \$comment_id of function get_comment_type expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$comment_id of function wp_delete_comment expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$comment_id of function wp_trash_comment expects int\|WP_Comment, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#1 \$object_id of method WP_REST_Meta_Fields\:\:get_value\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$comment_id of method WP_REST_Comments_Controller\:\:handle_status_param\(\) expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$object_id of method WP_REST_Meta_Fields\:\:update_value\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php + - + message: '#^Parameter \#1 \$args of function get_taxonomies expects array, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php + - + message: '#^Parameter \#1 \$id of function get_block_template expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php + - + message: '#^Parameter \#1 \$id of method WP_REST_Templates_Controller\:\:prepare_links\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php + - + message: '#^Parameter \#2 \$value of method WP_HTTP_Response\:\:header\(\) expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php + - + message: '#^Parameter \#2 \$src of method WP_Dependencies\:\:add\(\) expects string\|false, true given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/script-loader.php + - + message: '#^Parameter \#1 \$object_id of function wp_remove_object_terms expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$object_id of function wp_set_object_terms expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$object_ids of function wp_get_object_terms expects array\\|int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$post_id of function update_post_meta expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$terms of function update_term_cache expects array\, list\\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#1 \$terms of function wp_update_term_count expects array\|int, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$taxonomy of function wp_update_term_count expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$newvalue of function ini_set expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/template.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/comments.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/footer.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/header.php + - + message: '#^Parameter \#3 \$replacement of function _deprecated_file expects string, null given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-compat/sidebar.php + - + message: '#^Parameter \#1 \$string of function strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/theme-templates.php + - + message: '#^Parameter \#1 \$string of function mb_strlen expects string, int\<2, max\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#1 \$user_id of function switch_to_user_locale expects int, string given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#3 \$control_callback of function wp_register_widget_control expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php + - + message: '#^Parameter \#3 \$output_callback of function wp_register_sidebar_widget expects callable\(\)\: mixed, '''' given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php + - + message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-nav-menu-widget.php + - + message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets/class-wp-widget-recent-comments.php + - + message: '#^Parameter \#1 \$text of function esc_html expects string, int given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-mail.php diff --git a/tests/phpstan/baselines/argument.unresolvableType.neon b/tests/phpstan/baselines/argument.unresolvableType.neon new file mode 100644 index 0000000000000..b6015731f858b --- /dev/null +++ b/tests/phpstan/baselines/argument.unresolvableType.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `argument.unresolvableType` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/argument.unresolvableType +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=argument.unresolvableType +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$array_arg of function uksort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 2 + path: ../../../src/wp-includes/cron.php diff --git a/tests/phpstan/baselines/arrayValues.list.neon b/tests/phpstan/baselines/arrayValues.list.neon new file mode 100644 index 0000000000000..630c8e6bd1879 --- /dev/null +++ b/tests/phpstan/baselines/arrayValues.list.neon @@ -0,0 +1,25 @@ +# PHPStan baseline for the `arrayValues.list` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/arrayValues.list +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=arrayValues.list +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: ../../../src/wp-admin/includes/image.php From 7d6fa3a8728c3d25ea173d3893a69c1f13d1b65a Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 5 Aug 2026 09:01:33 +0000 Subject: [PATCH 327/336] Tests: Add unit tests for `wp_privacy_exports_dir()`. This adds coverage for the personal data exports directory, verifying both the default location under the uploads directory and that the filter of the same name can override it. Developed in: https://github.com/WordPress/wordpress-develop/pull/5553 Props desrosj, masteradhoc, mindctrl, pbearne, wildworks. Fixes #59710. git-svn-id: https://develop.svn.wordpress.org/trunk@63025 602fd350-edb4-49c9-b593-d223f7449a82 --- .../tests/functions/wpPrivacyExportsDir.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/phpunit/tests/functions/wpPrivacyExportsDir.php diff --git a/tests/phpunit/tests/functions/wpPrivacyExportsDir.php b/tests/phpunit/tests/functions/wpPrivacyExportsDir.php new file mode 100644 index 0000000000000..ddf068e4d8475 --- /dev/null +++ b/tests/phpunit/tests/functions/wpPrivacyExportsDir.php @@ -0,0 +1,43 @@ +assertSame( $expected, wp_privacy_exports_dir() ); + } + + /** + * @ticket 59710 + */ + public function test_wp_privacy_exports_dir_filtered() { + add_filter( 'wp_privacy_exports_dir', array( $this, 'filter_wp_privacy_exports_dir' ) ); + + $upload_dir = wp_upload_dir(); + $expected_dir = trailingslashit( $upload_dir['basedir'] ) . 'filtered-exports/'; + $actual_dir = wp_privacy_exports_dir(); + $this->assertSame( $expected_dir, $actual_dir ); + + remove_filter( 'wp_privacy_exports_dir', array( $this, 'filter_wp_privacy_exports_dir' ) ); + } + + /** + * Filters the personal data exports directory for tests. + * + * @param string $exports_dir Default exports directory. + * @return string Filtered exports directory. + */ + public function filter_wp_privacy_exports_dir( $exports_dir ) { + return str_replace( 'wp-personal-data-exports/', 'filtered-exports/', $exports_dir ); + } +} From 08993c360f31447edca778db953c9baacbb46e0d Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 5 Aug 2026 12:07:22 +0000 Subject: [PATCH 328/336] General: Bump the pinned hash for Gutenberg to `f05e40`. This updates the pinned commit hash of the Gutenberg repository from `fd715a6833679d098d9fee84b642f8f1bc27341b` to `f05e40e91c54f29c449b1f33d0db89f5166812d9`. A full list of changes included in this commit can be found on GitHub: https://github.com/WordPress/gutenberg/compare/fd715a6833679d098d9fee84b642f8f1bc27341b...f05e40e91c54f29c449b1f33d0db89f5166812d9 - Writing flow: forward delete an empty paragraph without breaking apart the next block (https://github.com/WordPress/gutenberg/pull/80813) - Upload Media: Fail the item when the /finalize request fails (https://github.com/WordPress/gutenberg/pull/80725) - Fix template `modified` and `date` return value for file templates (https://github.com/WordPress/gutenberg/pull/80733) - Boot: Adjust specificity of the image reset styles so components can size their own images (https://github.com/WordPress/gutenberg/pull/80845) - Quote: Ensure paragraph placeholder appears after deleting nested blocks (https://github.com/WordPress/gutenberg/pull/77151) - Block editor: make the Group action wrap blocks with a group transform (https://github.com/WordPress/gutenberg/pull/80891) - Copy: preserve the block when its entire text is selected (https://github.com/WordPress/gutenberg/pull/80994) - Add opt-out for block style state controls (https://github.com/WordPress/gutenberg/pull/80956) (https://github.com/WordPress/gutenberg/pull/81004) - Tabs: Support Home and End keys for keyboard navigation (https://github.com/WordPress/gutenberg/pull/80912) - Rename blockStatesEnabled setting to blockStatesEditingEnabled (https://github.com/WordPress/gutenberg/pull/81058) - [WP 7.1] Background: Fix the legacy gradient UI where a gradient cannot be selected (https://github.com/WordPress/gutenberg/pull/81059) - Views: honor developer-defined view config overrides (https://github.com/WordPress/gutenberg/pull/80832) - Playlist: Add track icon (https://github.com/WordPress/gutenberg/pull/81078) - Remove the CODEOWNERS file from wp/7.1. (https://github.com/WordPress/gutenberg/pull/81104) - Notes: Email users mentioned in a note (https://github.com/WordPress/gutenberg/pull/79606) - Backport 81068 80744 80642 (https://github.com/WordPress/gutenberg/pull/81135) - Site Editor: Add E2E coverage for view config extensibility (https://github.com/WordPress/gutenberg/pull/80577) - change from https://github.com/WordPress/gutenberg/pull/81068/ (https://github.com/WordPress/gutenberg/pull/81140) - Link Control: Restore the preview title underline (https://github.com/WordPress/gutenberg/pull/81083) - Button: Suppress UA focus ring when focused and pressed (https://github.com/WordPress/gutenberg/pull/81113) - View config: add reference docs (https://github.com/WordPress/gutenberg/pull/81149) - Editor: Fix document tools button focus ring (https://github.com/WordPress/gutenberg/pull/81115) - Interface: Increase footer breadcrumb height to prevent focus ring clipping (https://github.com/WordPress/gutenberg/pull/81156) - Post editor: Add ThemeProvider for admin color schemes (https://github.com/WordPress/gutenberg/pull/81112) - Pass Playlist controls to track blocks (https://github.com/WordPress/gutenberg/pull/81158) - Theme: Omit color properties when neither provided nor inherited (https://github.com/WordPress/gutenberg/pull/80600) (https://github.com/WordPress/gutenberg/pull/81172) - Media: Improve the HEIC upload error and keep any upload errors up until dismissed (https://github.com/WordPress/gutenberg/pull/81130) - Video: Hide settings for the GIF variation (https://github.com/WordPress/gutenberg/pull/81142) - Video: clarify the Video variation description (https://github.com/WordPress/gutenberg/pull/81181) - Button: turn on the width setting by default in theme.json (https://github.com/WordPress/gutenberg/pull/81196) - Edit Widgets: Fix header toolbar button focus ring (https://github.com/WordPress/gutenberg/pull/81176) - Build: Wrap script bundles in an IIFE to contain 'use strict' (https://github.com/WordPress/gutenberg/pull/79792) - Customizer widgets: Add ThemeProvider for admin color schemes (https://github.com/WordPress/gutenberg/pull/81174) - Fix: Tabs block: Start with empty tab labels with placeholders (https://github.com/WordPress/gutenberg/pull/81197) - PanelColorSettings: Restore the missing space below the panel header (https://github.com/WordPress/gutenberg/pull/81155) - Visual revisions: add shareable urls (https://github.com/WordPress/gutenberg/pull/81205) - Notes: fix the mention notification email composition (https://github.com/WordPress/gutenberg/pull/81187) - Fix ESLint warnings for 'navigateRegionsProps' spread (https://github.com/WordPress/gutenberg/pull/81208) - Widgets editor: Add ThemeProvider for admin color schemes (https://github.com/WordPress/gutenberg/pull/81173) - Remove the editableRoot opt-in from the paragraph block (https://github.com/WordPress/gutenberg/pull/81184) - Media Attached to: Fix issue with the popover unexpectedly flipping, tweak wording (https://github.com/WordPress/gutenberg/pull/81206) - Ensure device preview is always accurate when window is zoomed in (https://github.com/WordPress/gutenberg/pull/81215) Props wildworks. See #65529. git-svn-id: https://develop.svn.wordpress.org/trunk@63026 602fd350-edb4-49c9-b593-d223f7449a82 --- package.json | 2 +- .../assets/script-loader-packages.php | 138 +++++++++--------- .../assets/script-modules-packages.php | 8 +- src/wp-includes/blocks/blocks-json.php | 1 + src/wp-includes/blocks/playlist/block.json | 1 + .../build/routes/connectors-home/content.js | 8 +- .../connectors-home/content.min.asset.php | 2 +- .../routes/connectors-home/content.min.js | 2 +- src/wp-includes/theme.json | 1 + 9 files changed, 86 insertions(+), 77 deletions(-) diff --git a/package.json b/package.json index 5264d752b8e4e..1fa7810849728 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "fd715a6833679d098d9fee84b642f8f1bc27341b", + "sha": "f05e40e91c54f29c449b1f33d0db89f5166812d9", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/wp-includes/assets/script-loader-packages.php b/src/wp-includes/assets/script-loader-packages.php index 47dd96b9b3485..6b0ed2811e9d4 100644 --- a/src/wp-includes/assets/script-loader-packages.php +++ b/src/wp-includes/assets/script-loader-packages.php @@ -4,7 +4,7 @@ 'wp-dom-ready', 'wp-i18n' ), - 'version' => '483af07a6016f640f456' + 'version' => '31c6cec5a4ff7aff483d' ), 'annotations.js' => array( 'dependencies' => array( @@ -13,7 +13,7 @@ 'wp-i18n', 'wp-rich-text' ), - 'version' => 'd4fe1eeb787c2fd5ee89' + 'version' => '348a030f1b5717cfaba4' ), 'api-fetch.js' => array( 'dependencies' => array( @@ -21,25 +21,25 @@ 'wp-private-apis', 'wp-url' ), - 'version' => 'b5b51750518787a93005' + 'version' => '6f2a4faeee3c722b1e57' ), 'autop.js' => array( 'dependencies' => array( ), - 'version' => '9d0d0901b46f0a9027c9' + 'version' => '4e10a18cb6f21a043fc0' ), 'base-styles.js' => array( 'dependencies' => array( ), - 'version' => '8ebe97b095beb7e9279b' + 'version' => '67fd7250ac73fa2feba5' ), 'blob.js' => array( 'dependencies' => array( ), - 'version' => '198af75fe06d924090d8' + 'version' => 'c7582a735ddd2edc9731' ), 'block-directory.js' => array( 'dependencies' => array( @@ -66,7 +66,7 @@ 'wp-theme', 'wp-url' ), - 'version' => 'e534a0f04643c4175bf3' + 'version' => '7c679438bdaf9853987a' ), 'block-editor.js' => array( 'dependencies' => array( @@ -104,7 +104,7 @@ 'wp-url', 'wp-warning' ), - 'version' => 'b1292aac86a5d819f737' + 'version' => '3e3993ced88d35b1fafc' ), 'block-library.js' => array( 'dependencies' => array( @@ -150,19 +150,19 @@ 'import' => 'dynamic' ) ), - 'version' => '97b70e2d8d72d9b83b4e' + 'version' => '6569ec7523b3693a2b2e' ), 'block-serialization-default-parser.js' => array( 'dependencies' => array( ), - 'version' => 'bff55bd3f1ce9df0c99c' + 'version' => '4c6f3dd40077f7c17604' ), 'block-serialization-spec-parser.js' => array( 'dependencies' => array( ), - 'version' => '9ebc5e95e1de1cabd1e6' + 'version' => '7b0b496c3d48b1ef3f9e' ), 'blocks.js' => array( 'dependencies' => array( @@ -183,7 +183,7 @@ 'wp-shortcode', 'wp-warning' ), - 'version' => '524509cfc84da30a4133' + 'version' => 'c0e57a630a0b6f6c3bb5' ), 'commands.js' => array( 'dependencies' => array( @@ -199,7 +199,7 @@ 'wp-primitives', 'wp-private-apis' ), - 'version' => '148d9b31ef4d2952561e' + 'version' => '1a4910212c7ed2355300' ), 'components.js' => array( 'dependencies' => array( @@ -224,7 +224,7 @@ 'wp-theme', 'wp-warning' ), - 'version' => 'd5254b2fdf63282d09f7' + 'version' => '7937a1d6ffdc16e88517' ), 'compose.js' => array( 'dependencies' => array( @@ -239,7 +239,7 @@ 'wp-private-apis', 'wp-undo-manager' ), - 'version' => '6176e314156a3d1f9501' + 'version' => '0e8bde2a499ea6073b42' ), 'core-commands.js' => array( 'dependencies' => array( @@ -256,7 +256,7 @@ 'wp-router', 'wp-url' ), - 'version' => '8fc41d3503f7892d3ed8' + 'version' => '426a33508599b7f31db3' ), 'core-data.js' => array( 'dependencies' => array( @@ -277,7 +277,7 @@ 'wp-url', 'wp-warning' ), - 'version' => 'c7a571126b75599516cf' + 'version' => 'f0176a9c136b2962fdc4' ), 'customize-widgets.js' => array( 'dependencies' => array( @@ -306,7 +306,13 @@ 'wp-theme', 'wp-widgets' ), - 'version' => '05ff2e24b332f5dc0ea1' + 'module_dependencies' => array( + array( + 'id' => '@wordpress/route', + 'import' => 'static' + ) + ), + 'version' => 'a73c35651dc8614d5fb3' ), 'data.js' => array( 'dependencies' => array( @@ -319,7 +325,7 @@ 'wp-private-apis', 'wp-redux-routine' ), - 'version' => 'c547bd40753de57cdc64' + 'version' => '14a216e0932d72c22976' ), 'data-controls.js' => array( 'dependencies' => array( @@ -327,32 +333,32 @@ 'wp-data', 'wp-deprecated' ), - 'version' => '730061ade69d7f341014' + 'version' => '7e8f932da184d5537725' ), 'date.js' => array( 'dependencies' => array( 'moment', 'wp-deprecated' ), - 'version' => '2faaf49020b2074de156' + 'version' => '8173fc0fc12b7bb7eaf0' ), 'deprecated.js' => array( 'dependencies' => array( 'wp-hooks' ), - 'version' => '990e85f234fee8f7d446' + 'version' => 'fe587bac92b7d0ef760e' ), 'dom.js' => array( 'dependencies' => array( 'wp-deprecated' ), - 'version' => 'e13e9a880cb4f091f98e' + 'version' => 'c95f94cbbc1ac3fde84f' ), 'dom-ready.js' => array( 'dependencies' => array( ), - 'version' => 'a06281ae5cf5500e9317' + 'version' => '3fe927cab37bf38d6a23' ), 'edit-post.js' => array( 'dependencies' => array( @@ -396,7 +402,7 @@ 'import' => 'static' ) ), - 'version' => '823ecf7905c05ce03022' + 'version' => 'e566fa04fc489a642398' ), 'edit-site.js' => array( 'dependencies' => array( @@ -446,7 +452,7 @@ 'import' => 'static' ) ), - 'version' => '64590e045eedae65347d' + 'version' => 'b818e670c0d0297645f2' ), 'edit-widgets.js' => array( 'dependencies' => array( @@ -487,7 +493,7 @@ 'import' => 'static' ) ), - 'version' => '9d38df85a4b408821722' + 'version' => 'a84bb1dba0b91cf80efa' ), 'editor.js' => array( 'dependencies' => array( @@ -537,7 +543,7 @@ 'import' => 'static' ) ), - 'version' => '2f1a5efaa6f78167e6c7' + 'version' => 'e3c5b4a412541c51a59d' ), 'element.js' => array( 'dependencies' => array( @@ -545,13 +551,13 @@ 'react-dom', 'wp-escape-html' ), - 'version' => 'ce395381f7d64d2a6d71' + 'version' => '4a4370b2b349066fd440' ), 'escape-html.js' => array( 'dependencies' => array( ), - 'version' => '3f093e5cca67aa0f8b56' + 'version' => '87ebe53e97bba59805a5' ), 'format-library.js' => array( 'dependencies' => array( @@ -578,31 +584,31 @@ 'import' => 'dynamic' ) ), - 'version' => 'fc1a40ac6923d97797a4' + 'version' => 'd69ac704b4a81b89c946' ), 'hooks.js' => array( 'dependencies' => array( ), - 'version' => '7496969728ca0f95732d' + 'version' => 'f0f188028580e8dc1255' ), 'html-entities.js' => array( 'dependencies' => array( ), - 'version' => '8c6fa5b869dfeadc4af2' + 'version' => 'a976ff3a0f00bc2999a3' ), 'i18n.js' => array( 'dependencies' => array( 'wp-hooks' ), - 'version' => '125448662852c5e18937' + 'version' => '1dfe7db3940c23ea9216' ), 'is-shallow-equal.js' => array( 'dependencies' => array( ), - 'version' => '5d84b9f3cb50d2ce7d04' + 'version' => '7ad271045c1fe60f5496' ), 'keyboard-shortcuts.js' => array( 'dependencies' => array( @@ -611,13 +617,13 @@ 'wp-element', 'wp-keycodes' ), - 'version' => '0dd268b2132a3f82b1d4' + 'version' => '37da95806f2339bc80d0' ), 'keycodes.js' => array( 'dependencies' => array( 'wp-i18n' ), - 'version' => 'b156d58a707bff518176' + 'version' => 'd0b4204e4bbeb412df6e' ), 'list-reusable-blocks.js' => array( 'dependencies' => array( @@ -629,7 +635,7 @@ 'wp-element', 'wp-i18n' ), - 'version' => 'a44da9be02cdfef6e44d' + 'version' => '68a57d388ce085b9691e' ), 'media-utils.js' => array( 'dependencies' => array( @@ -657,7 +663,7 @@ 'wp-url', 'wp-warning' ), - 'version' => '8addf2ae46aa60243073' + 'version' => 'b8bf604c1cc119e63ee6' ), 'notices.js' => array( 'dependencies' => array( @@ -665,14 +671,14 @@ 'wp-components', 'wp-data' ), - 'version' => '505026883bbd05994872' + 'version' => 'c09a068fdab0eb465e14' ), 'nux.js' => array( 'dependencies' => array( 'wp-data', 'wp-deprecated' ), - 'version' => 'b0afe722eacfd6e9a364' + 'version' => '1a78c05bba2c02820a7e' ), 'patterns.js' => array( 'dependencies' => array( @@ -695,7 +701,7 @@ 'wp-theme', 'wp-url' ), - 'version' => '1d5dc833056614a65601' + 'version' => 'be5af192f57cc14d340f' ), 'plugins.js' => array( 'dependencies' => array( @@ -707,7 +713,7 @@ 'wp-is-shallow-equal', 'wp-primitives' ), - 'version' => '50bcc9bb42e4c0723a8c' + 'version' => '673d1e05ca49004ab160' ), 'preferences.js' => array( 'dependencies' => array( @@ -723,32 +729,32 @@ 'wp-primitives', 'wp-private-apis' ), - 'version' => 'ba5e81b3db928d4649c6' + 'version' => '5a169e3fc0e657f74172' ), 'preferences-persistence.js' => array( 'dependencies' => array( 'wp-api-fetch' ), - 'version' => 'e8033be98338d1861bca' + 'version' => 'a34abbdacd8f50f9acb1' ), 'primitives.js' => array( 'dependencies' => array( 'react-jsx-runtime', 'wp-element' ), - 'version' => 'a5c905ec27bcd76ef287' + 'version' => '44cc5a35c7b9fe07a838' ), 'priority-queue.js' => array( 'dependencies' => array( ), - 'version' => '1f0e89e247bc0bd3f9b9' + 'version' => '6c0aa59b65d55dfd509b' ), 'private-apis.js' => array( 'dependencies' => array( ), - 'version' => 'd253db066c622f144ae7' + 'version' => 'eb85f28c4c729bb4f002' ), 'react-i18n.js' => array( 'dependencies' => array( @@ -756,13 +762,13 @@ 'wp-element', 'wp-i18n' ), - 'version' => '9b74577dbd7e50f6b77b' + 'version' => 'ba2bd3d7a3817f0494af' ), 'redux-routine.js' => array( 'dependencies' => array( ), - 'version' => '64f9f5001aabc046c605' + 'version' => 'acca2b4857d83ad1790e' ), 'reusable-blocks.js' => array( 'dependencies' => array( @@ -779,7 +785,7 @@ 'wp-primitives', 'wp-url' ), - 'version' => '00a57a244d360831336a' + 'version' => '5161508c6662b8490ee8' ), 'rich-text.js' => array( 'dependencies' => array( @@ -794,7 +800,7 @@ 'wp-keycodes', 'wp-private-apis' ), - 'version' => '9f145f4a11c41d022c83' + 'version' => '3e5852e42cee1c239bae' ), 'router.js' => array( 'dependencies' => array( @@ -804,7 +810,7 @@ 'wp-private-apis', 'wp-url' ), - 'version' => '0249e6724784b1c2583b' + 'version' => 'dda75cd9ff9d7e0eb19f' ), 'server-side-render.js' => array( 'dependencies' => array( @@ -818,19 +824,19 @@ 'wp-i18n', 'wp-url' ), - 'version' => '77621917ec58330ec283' + 'version' => '83e806a0634df6b93530' ), 'shortcode.js' => array( 'dependencies' => array( ), - 'version' => '11742fe18cc215d3d5ab' + 'version' => 'f6273476300cc5fad4cd' ), 'style-engine.js' => array( 'dependencies' => array( ), - 'version' => '50b0461aa90d44c4123b' + 'version' => '914befb08774033e6265' ), 'sync.js' => array( 'dependencies' => array( @@ -838,7 +844,7 @@ 'wp-hooks', 'wp-private-apis' ), - 'version' => '82121af3ec5dd7ba0296' + 'version' => '15f3a34404da1c4bb483' ), 'theme.js' => array( 'dependencies' => array( @@ -848,19 +854,19 @@ 'wp-element', 'wp-private-apis' ), - 'version' => 'f017490f1df372de8462' + 'version' => '48f91740a3d737558e9c' ), 'token-list.js' => array( 'dependencies' => array( ), - 'version' => '16f0aebdd39d87c2a84b' + 'version' => 'e86ab419d8302d57822c' ), 'undo-manager.js' => array( 'dependencies' => array( 'wp-is-shallow-equal' ), - 'version' => '27bb0ae036a2c9d4a1b5' + 'version' => '4554fce6276d8910a4ae' ), 'upload-media.js' => array( 'dependencies' => array( @@ -883,13 +889,13 @@ 'import' => 'dynamic' ) ), - 'version' => 'a16fcecc49ab54f868c2' + 'version' => 'f7174b0617bcd68e57c3' ), 'url.js' => array( 'dependencies' => array( ), - 'version' => '9dd5f16a5ce37bf4ba2c' + 'version' => '7b0de086d4ae11d55704' ), 'viewport.js' => array( 'dependencies' => array( @@ -897,13 +903,13 @@ 'wp-data', 'wp-element' ), - 'version' => '83b39beb77dcc56c4d26' + 'version' => 'a56e3489ed4faeac7720' ), 'warning.js' => array( 'dependencies' => array( ), - 'version' => '36fdbdc984d93aee8a97' + 'version' => 'a0978839debc564a6608' ), 'widgets.js' => array( 'dependencies' => array( @@ -920,12 +926,12 @@ 'wp-notices', 'wp-primitives' ), - 'version' => '2a2e101698084ec9e2c3' + 'version' => '087235ca647aa1a33227' ), 'wordcount.js' => array( 'dependencies' => array( ), - 'version' => 'f53ba7c5b085d7a53357' + 'version' => 'f0b1f0e977b2ff6e0132' ) ); \ No newline at end of file diff --git a/src/wp-includes/assets/script-modules-packages.php b/src/wp-includes/assets/script-modules-packages.php index fc6e0c98dd365..1213445dd9d48 100644 --- a/src/wp-includes/assets/script-modules-packages.php +++ b/src/wp-includes/assets/script-modules-packages.php @@ -128,7 +128,7 @@ 'import' => 'static' ) ), - 'version' => '581cf5c9168a7665f2dd' + 'version' => 'cc1a34b1bee3c2e17bc4' ), 'boot/index.js' => array( 'dependencies' => array( @@ -164,7 +164,7 @@ 'import' => 'static' ) ), - 'version' => '4b0281842169241e3d0e' + 'version' => 'e6158521d3acdf579ed2' ), 'connectors/index.js' => array( 'dependencies' => array( @@ -211,7 +211,7 @@ 'import' => 'static' ) ), - 'version' => '2fe152df83cad8d59403' + 'version' => 'b9a1df775b12692a9ffb' ), 'core-abilities/index.js' => array( 'dependencies' => array( @@ -247,7 +247,7 @@ 'import' => 'static' ) ), - 'version' => '35485e5cfea4689dcaa1' + 'version' => 'e2f82d3d1c3179d25626' ), 'interactivity/index.js' => array( 'dependencies' => array( diff --git a/src/wp-includes/blocks/blocks-json.php b/src/wp-includes/blocks/blocks-json.php index d37e7583dd027..4e3a2878536a4 100644 --- a/src/wp-includes/blocks/blocks-json.php +++ b/src/wp-includes/blocks/blocks-json.php @@ -4975,6 +4975,7 @@ 'supports' => array( 'anchor' => true, 'align' => true, + '__experimentalExposeControlsToChildren' => true, 'color' => array( 'gradients' => true, 'link' => true, diff --git a/src/wp-includes/blocks/playlist/block.json b/src/wp-includes/blocks/playlist/block.json index 796b3d580e6a6..566174e50d3c9 100644 --- a/src/wp-includes/blocks/playlist/block.json +++ b/src/wp-includes/blocks/playlist/block.json @@ -69,6 +69,7 @@ "supports": { "anchor": true, "align": true, + "__experimentalExposeControlsToChildren": true, "color": { "gradients": true, "link": true, diff --git a/src/wp-includes/build/routes/connectors-home/content.js b/src/wp-includes/build/routes/connectors-home/content.js index 186807ef4d8e6..521cbcd5b9dc1 100644 --- a/src/wp-includes/build/routes/connectors-home/content.js +++ b/src/wp-includes/build/routes/connectors-home/content.js @@ -8937,9 +8937,9 @@ if (typeof process === "undefined" || true) { } var resets_default = { "box-sizing": "_336cd3e4e743482f__box-sizing" }; if (typeof process === "undefined" || true) { - registerStyle3("5f8e7aa0bc", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}"); + registerStyle3("da99a163ac", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}"); } -var focus_default = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible" }; +var focus_default = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active" }; if (typeof process === "undefined" || true) { registerStyle3("af6d9984a6", "._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}"); } @@ -9904,9 +9904,9 @@ if (typeof process === "undefined" || true) { } var resets_default3 = { "box-sizing": "_336cd3e4e743482f__box-sizing" }; if (typeof process === "undefined" || true) { - registerStyle10("5f8e7aa0bc", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}"); + registerStyle10("da99a163ac", "@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}"); } -var focus_default2 = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible" }; +var focus_default2 = { "outset-ring--focus": "_08e8a2e44959f892__outset-ring--focus", "outset-ring--focus-visible": "d0541bc9dd9dc7b6__outset-ring--focus-visible", "outset-ring--focus-within": "cd83dfc2126a0846__outset-ring--focus-within", "outset-ring--focus-within-visible": "c5cb3ee4bddaa8e4__outset-ring--focus-within-visible", "outset-ring--focus-parent-visible": "ecadb9e080e2dfa5__outset-ring--focus-parent-visible", "outset-ring--focus-except-active": "e25b2bdd7aa21721__outset-ring--focus-except-active", "outset-ring--focus-within-except-active": "_970d04df7376df67__outset-ring--focus-within-except-active" }; if (typeof process === "undefined" || true) { registerStyle10("e8e6a9be37", '@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}'); } diff --git a/src/wp-includes/build/routes/connectors-home/content.min.asset.php b/src/wp-includes/build/routes/connectors-home/content.min.asset.php index 666c2ec30313d..d033468a89d76 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.asset.php +++ b/src/wp-includes/build/routes/connectors-home/content.min.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '22188cb77ae78d025593'); \ No newline at end of file + array('react', 'react-dom', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/connectors', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '1c478cb5cadaf4aded06'); \ No newline at end of file diff --git a/src/wp-includes/build/routes/connectors-home/content.min.js b/src/wp-includes/build/routes/connectors-home/content.min.js index 3f51fd2690b81..62f17f892185e 100644 --- a/src/wp-includes/build/routes/connectors-home/content.min.js +++ b/src/wp-includes/build/routes/connectors-home/content.min.js @@ -1,4 +1,4 @@ -var wf=Object.create;var _r=Object.defineProperty;var vf=Object.getOwnPropertyDescriptor;var _f=Object.getOwnPropertyNames;var yf=Object.getPrototypeOf,xf=Object.prototype.hasOwnProperty;var Re=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),At=(e,t)=>{for(var o in t)_r(e,o,{get:t[o],enumerable:!0})},Rf=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of _f(t))!xf.call(e,r)&&r!==o&&_r(e,r,{get:()=>t[r],enumerable:!(n=vf(t,r))||n.enumerable});return e};var h=(e,t,o)=>(o=e!=null?wf(yf(e)):{},Rf(t||!e||!e.__esModule?_r(o,"default",{value:e,enumerable:!0}):o,e));var Ot=Re((g0,Gs)=>{Gs.exports=window.wp.i18n});var de=Re((h0,Ks)=>{Ks.exports=window.wp.element});var z=Re((w0,qs)=>{qs.exports=window.React});var Q=Re((E0,$s)=>{$s.exports=window.ReactJSXRuntime});var Mt=Re((Ah,Ta)=>{Ta.exports=window.ReactDOM});var Mc=Re(Ic=>{"use strict";var wo=z();function Tm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var km=typeof Object.is=="function"?Object.is:Tm,Pm=wo.useState,Cm=wo.useEffect,Am=wo.useLayoutEffect,Om=wo.useDebugValue;function Nm(e,t){var o=t(),n=Pm({inst:{value:o,getSnapshot:t}}),r=n[0].inst,i=n[1];return Am(function(){r.value=o,r.getSnapshot=t,ri(r)&&i({inst:r})},[e,o,t]),Cm(function(){return ri(r)&&i({inst:r}),e(function(){ri(r)&&i({inst:r})})},[e]),Om(o),o}function ri(e){var t=e.getSnapshot;e=e.value;try{var o=t();return!km(e,o)}catch{return!0}}function Lm(e,t){return t()}var Im=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?Lm:Nm;Ic.useSyncExternalStore=wo.useSyncExternalStore!==void 0?wo.useSyncExternalStore:Im});var ii=Re((w1,Bc)=>{"use strict";Bc.exports=Mc()});var zc=Re(Hc=>{"use strict";var Dn=z(),Mm=ii();function Bm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Hm=typeof Object.is=="function"?Object.is:Bm,zm=Mm.useSyncExternalStore,Dm=Dn.useRef,jm=Dn.useEffect,Fm=Dn.useMemo,Vm=Dn.useDebugValue;Hc.useSyncExternalStoreWithSelector=function(e,t,o,n,r){var i=Dm(null);if(i.current===null){var s={hasValue:!1,value:null};i.current=s}else s=i.current;i=Fm(function(){function d(m){if(!c){if(c=!0,l=m,m=n(m),r!==void 0&&s.hasValue){var u=s.value;if(r(u,m))return f=u}return f=m}if(u=f,Hm(l,m))return u;var g=n(m);return r!==void 0&&r(u,g)?(l=m,u):(l=m,f=g)}var c=!1,l,f,p=o===void 0?null:o;return[function(){return d(t())},p===null?void 0:function(){return d(p())}]},[t,o,n,r]);var a=zm(e,i[0],i[1]);return jm(function(){s.hasValue=!0,s.value=a},[a]),Vm(a),a}});var jc=Re((_1,Dc)=>{"use strict";Dc.exports=zc()});var $t=Re((X2,md)=>{md.exports=window.wp.primitives});var Rd=Re((g4,xd)=>{xd.exports=window.wp.theme});var Qi=Re((b4,Sd)=>{Sd.exports=window.wp.privateApis});var on=Re((q5,Au)=>{Au.exports=window.wp.components});var rn=Re((a3,zu)=>{zu.exports=window.wp.data});var mr=Re((c3,Du)=>{Du.exports=window.wp.coreData});var Hs=Re((u3,Fu)=>{Fu.exports=window.wp.notices});var Wu=Re((f3,Vu)=>{Vu.exports=window.wp.url});function Xs(e){var t,o,n="";if(typeof e=="string"||typeof e=="number")n+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;te();function Y(e){let t=Se(kf).current;return t.next=e,Tf(t.effect),t.trampoline}function kf(){let e={next:void 0,callback:Pf,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function Pf(){}var Js=h(z(),1),Cf=()=>{},D=typeof document<"u"?Js.useLayoutEffect:Cf;var hn=h(z(),1),Af=hn.createContext(void 0);function so(){return hn.useContext(Af)?.direction??"ltr"}function Of(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set("code",n.toString()),r.forEach(s=>i.searchParams.append("args[]",s)),`${t} error #${n}; visit ${i} for the full message.`}}var Nf=Of("https://base-ui.com/production-error","Base UI"),Pe=Nf;var Wt=h(z(),1);function xr(e,t,o,n){let r=Se(ta).current;return Lf(r,e,t,o,n)&&oa(r,[e,t,o,n]),r.callback}function ea(e){let t=Se(ta).current;return If(t,e)&&oa(t,e),t.callback}function ta(){return{callback:null,cleanup:null,refs:[]}}function Lf(e,t,o,n,r){return e.refs[0]!==t||e.refs[1]!==o||e.refs[2]!==n||e.refs[3]!==r}function If(e,t){return e.refs.length!==t.length||e.refs.some((o,n)=>o!==t[n])}function oa(e,t){if(e.refs=t,t.every(o=>o==null)){e.callback=null;return}e.callback=o=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),o!=null){let n=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function Rr(e){if(!ra.isValidElement(e))return null;let t=e,o=t.props;return(ao(19)?o?.ref:t.ref)??null}function Bo(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function Nt(){}var I0=Object.freeze([]),be=Object.freeze({});function ia(e,t){let o={};for(let n in e){let r=e[n];if(t?.hasOwnProperty(n)){let i=t[n](r);i!=null&&Object.assign(o,i);continue}r===!0?o[`data-${n.toLowerCase()}`]="":r&&(o[`data-${n.toLowerCase()}`]=r.toString())}return o}function sa(e,t){return typeof e=="function"?e(t):e}function aa(e,t){return typeof e=="function"?e(t):e}var Sr={};function ye(e,t,o,n,r){if(!o&&!n&&!r&&!e)return wn(t);let i=wn(e);return t&&(i=Ho(i,t)),o&&(i=Ho(i,o)),n&&(i=Ho(i,n)),r&&(i=Ho(i,r)),i}function ca(e){if(e.length===0)return Sr;if(e.length===1)return wn(e[0]);let t=wn(e[0]);for(let o=1;o=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function Er(e){return typeof e=="function"}function da(e,t){return Er(e)?e(t):e??Sr}function zf(e,t){return t?e?(...o)=>{let n=o[0];if(fa(n)){let i=n;zo(i);let s=t(...o);return i.baseUIHandlerPrevented||e?.(...o),s}let r=t(...o);return e?.(...o),r}:ua(t):e}function ua(e){return e&&((...t)=>{let o=t[0];return fa(o)&&zo(o),e(...t)})}function zo(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Tr(e,t){return t?e?t+" "+e:t:e}function fa(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var kr=h(z(),1);function Ce(e,t,o={}){let n=t.render,r=Df(t,o);if(o.enabled===!1)return null;let i=o.state??be;return Vf(e,n,r,i)}function Df(e,t={}){let{className:o,style:n,render:r}=e,{state:i=be,ref:s,props:a,stateAttributesMapping:d,enabled:c=!0}=t,l=c?sa(o,i):void 0,f=c?aa(n,i):void 0,p=c?ia(i,d):be,m=c&&a?jf(a):void 0,u=c?Bo(p,m)??{}:be;return typeof document<"u"&&(c?Array.isArray(s)?u.ref=ea([u.ref,Rr(r),...s]):u.ref=xr(u.ref,Rr(r),s):xr(null,null)),c?(l!==void 0&&(u.className=Tr(u.className,l)),f!==void 0&&(u.style=Bo(u.style,f)),u):be}function jf(e){return Array.isArray(e)?ca(e):ye(void 0,e)}var Ff=Symbol.for("react.lazy");function Vf(e,t,o,n){if(t){if(typeof t=="function")return t(o,n);let r=ye(o,t.props);r.ref=o.ref;let i=t;return i?.$$typeof===Ff&&(i=Wt.Children.toArray(t)[0]),Wt.cloneElement(i,r)}if(e&&typeof e=="string")return Wf(e,o);throw new Error(Pe(8))}function Wf(e,t){return e==="button"?(0,kr.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,kr.createElement)("img",{alt:"",...t,key:t.key}):Wt.createElement(e,t)}var vn=h(z(),1);var pa=0;function Yf(e,t="mui"){let[o,n]=vn.useState(e),r=e||o;return vn.useEffect(()=>{o==null&&(pa+=1,n(`${t}-${pa}`))},[o,t]),r}var ma=Mo.useId;function Lt(e,t){if(ma!==void 0){let o=ma();return e??(t?`${t}-${o}`:o)}return Yf(e,t)}function ga(e){return Lt(e,"base-ui")}var U={};At(U,{cancelOpen:()=>wp,chipRemovePress:()=>ep,clearPress:()=>$f,closePress:()=>Qf,closeWatcher:()=>up,decrementPress:()=>np,disabled:()=>_p,drag:()=>gp,escapeKey:()=>dp,focusOut:()=>lp,imperativeAction:()=>Rp,incrementPress:()=>op,initial:()=>xp,inputBlur:()=>sp,inputChange:()=>rp,inputClear:()=>ip,inputPaste:()=>ap,inputPress:()=>cp,itemPress:()=>Zf,keyboard:()=>pp,linkPress:()=>Jf,listNavigation:()=>fp,missing:()=>yp,none:()=>Uf,outsidePress:()=>qf,pointer:()=>mp,scrub:()=>hp,siblingOpen:()=>vp,swipe:()=>Sp,trackPress:()=>tp,triggerFocus:()=>Kf,triggerHover:()=>Xf,triggerPress:()=>Gf,wheel:()=>bp,windowResize:()=>Ep});var Uf="none",Gf="trigger-press",Xf="trigger-hover",Kf="trigger-focus",qf="outside-press",Zf="item-press",Qf="close-press",Jf="link-press",$f="clear-press",ep="chip-remove-press",tp="track-press",op="increment-press",np="decrement-press",rp="input-change",ip="input-clear",sp="input-blur",ap="input-paste",cp="input-press",lp="focus-out",dp="escape-key",up="close-watcher",fp="list-navigation",pp="keyboard",mp="pointer",gp="drag",bp="wheel",hp="scrub",wp="cancel-open",vp="sibling-open",_p="disabled",yp="missing",xp="initial",Rp="imperative-action",Sp="swipe",Ep="window-resize";function ee(e,t,o,n){let r=!1,i=!1,s=n??be;return{reason:e,event:t??new Event("base-ui"),cancel(){r=!0},allowPropagation(){i=!0},get isCanceled(){return r},get isPropagationAllowed(){return i},trigger:o,...s}}var Cr=h(z(),1);var ba=h(z(),1),Tp=[];function co(e){ba.useEffect(e,Tp)}var _n=null,ah=globalThis.requestAnimationFrame,Pr=class{callbacks=[];callbacksCount=0;nextId=1;startId=1;isScheduled=!1;tick=t=>{this.isScheduled=!1;let o=this.callbacks,n=this.callbacksCount;if(this.callbacks=[],this.callbacksCount=0,this.startId=this.nextId,n>0)for(let r=0;r=this.callbacks.length||(this.callbacks[o]=null,this.callbacksCount-=1)}},yn=new Pr,ft=class e{static create(){return new e}static request(t){return yn.request(t)}static cancel(t){return yn.cancel(t)}currentId=_n;request(t){this.cancel(),this.currentId=yn.request(()=>{this.currentId=_n,t()})}cancel=()=>{this.currentId!==_n&&(yn.cancel(this.currentId),this.currentId=_n)};disposeEffect=()=>this.cancel};function lo(){let e=Se(ft.create).current;return co(e.disposeEffect),e}function ha(e,t=!1,o=!1){let[n,r]=Cr.useState(e&&t?"idle":void 0),[i,s]=Cr.useState(e);return e&&!i&&(s(!0),r("starting")),!e&&i&&n!=="ending"&&!o&&r("ending"),!e&&!i&&n==="ending"&&r(void 0),D(()=>{if(!e&&i&&n!=="ending"&&o){let a=ft.request(()=>{r("ending")});return()=>{ft.cancel(a)}}},[e,i,n,o]),D(()=>{if(!e||t)return;let a=ft.request(()=>{r(void 0)});return()=>{ft.cancel(a)}},[t,e]),D(()=>{if(!e||!t)return;e&&i&&n!=="idle"&&r("starting");let a=ft.request(()=>{r("idle")});return()=>{ft.cancel(a)}},[t,e,i,n]),{mounted:i,setMounted:s,transitionStatus:n}}var Yt=(function(e){return e.startingStyle="data-starting-style",e.endingStyle="data-ending-style",e})({}),kp={[Yt.startingStyle]:""},Pp={[Yt.endingStyle]:""},wa={transitionStatus(e){return e==="starting"?kp:e==="ending"?Pp:null}};var po=h(z(),1);function xn(){return typeof window<"u"}function Gt(e){return Rn(e)?(e.nodeName||"").toLowerCase():"#document"}function ge(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function ot(e){var t;return(t=(Rn(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function Rn(e){return xn()?e instanceof Node||e instanceof ge(e).Node:!1}function V(e){return xn()?e instanceof Element||e instanceof ge(e).Element:!1}function we(e){return xn()?e instanceof HTMLElement||e instanceof ge(e).HTMLElement:!1}function uo(e){return!xn()||typeof ShadowRoot>"u"?!1:e instanceof ShadowRoot||e instanceof ge(e).ShadowRoot}function fo(e){let{overflow:t,overflowX:o,overflowY:n,display:r}=Ae(e);return/auto|scroll|overlay|hidden|clip/.test(t+n+o)&&r!=="inline"&&r!=="contents"}function va(e){return/^(table|td|th)$/.test(Gt(e))}function Do(e){try{if(e.matches(":popover-open"))return!0}catch{}try{return e.matches(":modal")}catch{return!1}}var Cp=/transform|translate|scale|rotate|perspective|filter/,Ap=/paint|layout|strict|content/,Ut=e=>!!e&&e!=="none",Ar;function Sn(e){let t=V(e)?Ae(e):e;return Ut(t.transform)||Ut(t.translate)||Ut(t.scale)||Ut(t.rotate)||Ut(t.perspective)||!En()&&(Ut(t.backdropFilter)||Ut(t.filter))||Cp.test(t.willChange||"")||Ap.test(t.contain||"")}function _a(e){let t=tt(e);for(;we(t)&&!nt(t);){if(Sn(t))return t;if(Do(t))return null;t=tt(t)}return null}function En(){return Ar==null&&(Ar=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),Ar}function nt(e){return/^(html|body|#document)$/.test(Gt(e))}function Ae(e){return ge(e).getComputedStyle(e)}function jo(e){return V(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function tt(e){if(Gt(e)==="html")return e;let t=e.assignedSlot||e.parentNode||uo(e)&&e.host||ot(e);return uo(t)?t.host:t}function ya(e){let t=tt(e);return nt(t)?e.ownerDocument?e.ownerDocument.body:e.body:we(t)&&fo(t)?t:ya(t)}function It(e,t,o){var n;t===void 0&&(t=[]),o===void 0&&(o=!0);let r=ya(e),i=r===((n=e.ownerDocument)==null?void 0:n.body),s=ge(r);if(i){let a=Tn(s);return t.concat(s,s.visualViewport||[],fo(r)?r:[],a&&o?It(a):[])}else return t.concat(r,It(r,[],o))}function Tn(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var kn=h(z(),1),Op=kn.createContext(void 0);function xa(e=!1){let t=kn.useContext(Op);if(t===void 0&&!e)throw new Error(Pe(16));return t}var Ra=h(z(),1);function Sa(e){let{focusableWhenDisabled:t,disabled:o,composite:n=!1,tabIndex:r=0,isNativeButton:i}=e,s=n&&t!==!1,a=n&&t===!1;return{props:Ra.useMemo(()=>{let c={onKeyDown(l){o&&t&&l.key!=="Tab"&&l.preventDefault()}};return n||(c.tabIndex=r,!i&&o&&(c.tabIndex=t?r:-1)),(i&&(t||s)||!i&&o)&&(c["aria-disabled"]=o),i&&(!t||a)&&(c.disabled=o),c},[n,o,t,s,a,i,r])}}function Ea(e={}){let{disabled:t=!1,focusableWhenDisabled:o,tabIndex:n=0,native:r=!0,composite:i}=e,s=po.useRef(null),a=xa(!0),d=i??a!==void 0,{props:c}=Sa({focusableWhenDisabled:o,disabled:t,composite:d,tabIndex:n,isNativeButton:r}),l=po.useCallback(()=>{let m=s.current;Or(m)&&d&&t&&c.disabled===void 0&&m.disabled&&(m.disabled=!1)},[t,c.disabled,d]);D(l,[l]);let f=po.useCallback((m={})=>{let{onClick:u,onMouseDown:g,onKeyUp:v,onKeyDown:_,onPointerDown:w,...y}=m;return ye({onClick(b){if(t){b.preventDefault();return}u?.(b)},onMouseDown(b){t||g?.(b)},onKeyDown(b){if(t||(zo(b),_?.(b),b.baseUIHandlerPrevented))return;let S=b.target===b.currentTarget,x=b.currentTarget,E=Or(x),T=!r&&Np(x),k=S&&(r?E:!T),C=b.key==="Enter",j=b.key===" ",A=x.getAttribute("role"),L=A?.startsWith("menuitem")||A==="option"||A==="gridcell";if(S&&d&&j){if(b.defaultPrevented&&L)return;b.preventDefault(),T||r&&E?(x.click(),b.preventBaseUIHandler()):k&&(u?.(b),b.preventBaseUIHandler());return}k&&(!r&&(j||C)&&b.preventDefault(),!r&&C&&u?.(b))},onKeyUp(b){if(!t){if(zo(b),v?.(b),b.target===b.currentTarget&&r&&d&&Or(b.currentTarget)&&b.key===" "){b.preventDefault();return}b.baseUIHandlerPrevented||b.target===b.currentTarget&&!r&&!d&&b.key===" "&&u?.(b)}},onPointerDown(b){if(t){b.preventDefault();return}w?.(b)}},r?{type:"button"}:{role:"button"},c,y)},[t,c,d,r]),p=Y(m=>{s.current=m,l()});return{getButtonProps:f,buttonRef:p}}function Or(e){return we(e)&&e.tagName==="BUTTON"}function Np(e){return!!(e?.tagName==="A"&&e?.href)}function re(e,t,o,n){return e.addEventListener(t,o,n),()=>{e.removeEventListener(t,o,n)}}function ze(e){let t=Se(Lp,e).current;return t.next=e,D(t.effect),t}function Lp(e){let t={current:e,next:e,effect:()=>{t.current=t.next}};return t}function xe(e){return e?.ownerDocument||document}var Ca=h(z(),1);var Pa=h(Mt(),1);function ka(e){return e==null?e:"current"in e?e.current:e}function mo(e,t=!1,o=!0){let n=lo();return Y((r,i=null)=>{n.cancel();let s=ka(e);if(s==null)return;let a=s,d=()=>{Pa.flushSync(r)};if(typeof a.getAnimations!="function"||globalThis.BASE_UI_ANIMATIONS_DISABLED){r();return}function c(){Promise.all(a.getAnimations().map(l=>l.finished)).then(()=>{i?.aborted||d()}).catch(()=>{if(o){i?.aborted||d();return}let l=a.getAnimations();!i?.aborted&&l.length>0&&l.some(f=>f.pending||f.playState!=="finished")&&c()})}if(t){let l=Yt.startingStyle;if(!a.hasAttribute(l)){n.request(c);return}let f=new MutationObserver(()=>{a.hasAttribute(l)||(f.disconnect(),c())});f.observe(a,{attributes:!0,attributeFilter:[l]}),i?.addEventListener("abort",()=>f.disconnect(),{once:!0});return}n.request(c)})}function Pn(e){let{enabled:t=!0,open:o,ref:n,onComplete:r}=e,i=Y(r),s=mo(n,o,!1);Ca.useEffect(()=>{if(!t)return;let a=new AbortController;return s(i,a.signal),()=>{a.abort()}},[t,o,i,s])}var Aa=h(z(),1);function Oa(e){let t=Aa.useRef(!0);t.current&&(t.current=!1,e())}var xt={};At(xt,{engine:()=>Br,env:()=>zr,os:()=>Ir,screenReader:()=>Hr});var Ir={};At(Ir,{android:()=>Ia,apple:()=>Lr,ios:()=>Nr,linux:()=>zp,mac:()=>Ma,windows:()=>Hp});function Ip(){return typeof navigator>"u"?{userAgent:"",platform:"",maxTouchPoints:0}:{userAgent:navigator.userAgent,platform:navigator.platform??"",maxTouchPoints:navigator.maxTouchPoints??0}}var{userAgent:Mp,platform:Bp,maxTouchPoints:Na}=Ip(),Xt=Mp.toLowerCase(),Kt=Bp.toLowerCase();var Nr=/^i(os$|p)/.test(Kt)||Kt==="macintel"&&Na>1,La="android",Ia=Kt===La||Xt.includes(La),Ma=!Nr&&Kt.startsWith("mac"),Hp=Kt.startsWith("win"),zp=!Ia&&/^(linux|chrome os)/.test(Kt),Lr=Ma||Nr;var Br={};At(Br,{blink:()=>jp,gecko:()=>Dp,webkit:()=>Mr});var Mr=typeof CSS<"u"&&!!CSS.supports?.("-webkit-backdrop-filter:none"),Dp=!Mr&&Xt.includes("firefox"),jp=!Mr&&Xt.includes("chrom");var Hr={};At(Hr,{voiceOver:()=>Fp});var Fp=Lr;var zr={};At(zr,{jsdom:()=>Vp});var Vp=/jsdom|happydom/.test(Xt);var Fo=0,Ye=class e{static create(){return new e}currentId=Fo;start(t,o){this.clear(),this.currentId=setTimeout(()=>{this.currentId=Fo,o()},t)}isStarted(){return this.currentId!==Fo}clear=()=>{this.currentId!==Fo&&(clearTimeout(this.currentId),this.currentId=Fo)};disposeEffect=()=>this.clear};function rt(){let e=Se(Ye.create).current;return co(e.disposeEffect),e}var Oe=h(z(),1);function Ba(e){return"nativeEvent"in e}function Rt(e,t){let o=["mouse","pen"];return t||o.push("",void 0),o.includes(e)}function Ha(e){let t=e.type;return t==="click"||t==="mousedown"||t==="keydown"||t==="keyup"}var Dr="data-base-ui-focusable";var jr="input:not([type='hidden']):not([disabled]),[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";function Cn(e){let t=e.activeElement;for(;t?.shadowRoot?.activeElement!=null;)t=t.shadowRoot.activeElement;return t}function ie(e,t){if(!e||!t)return!1;let o=t.getRootNode?.();if(e.contains(t))return!0;if(o&&uo(o)){let n=t;for(;n;){if(e===n)return!0;n=n.parentNode||n.host}}return!1}function Me(e){return"composedPath"in e?e.composedPath()[0]:e.target}function Bt(e,t){if(!V(e))return!1;let o=e;if(t.hasElement(o))return!o.hasAttribute("data-trigger-disabled");for(let[,n]of t.entries())if(ie(n,o))return!n.hasAttribute("data-trigger-disabled");return!1}function An(e,t){if(t==null)return!1;if("composedPath"in e)return e.composedPath().includes(t);let o=e;return o.target!=null&&t.contains(o.target)}function za(e){return e.matches("html,body")}function Da(e){return we(e)&&e.matches(jr)}function Fr(e){return e?.closest(`button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${jr}`)!=null}function ja(e){if(!e||xt.env.jsdom)return!0;try{return e.matches(":focus-visible")}catch{return!0}}function Wp(e,t){return t!=null&&!Rt(t)?0:typeof e=="function"?e():e}function St(e,t,o){let n=Wp(e,o);return typeof n=="number"?n:n?.[t]}function Vr(e){return typeof e=="function"?e():e}function On(e,t){return t||e==="click"||e==="mousedown"}function Fa(e){return e?.includes("mouse")&&e!=="mousedown"}var Va=h(Q(),1),Wa=Oe.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new Ye,currentIdRef:{current:null},currentContextRef:{current:null}});function Yp(e,t){e.current=t.current}function Wr(e){let{children:t,delay:o,timeoutMs:n=0}=e,r=Oe.useRef(o),i=Oe.useRef(o),s=Oe.useRef(null),a=Oe.useRef(null),d=rt();return D(()=>{if(i.current=o,!s.current){r.current=o;return}r.current={open:St(r.current,"open"),close:St(o,"close")}},[o,s,r,i]),(0,Va.jsx)(Wa.Provider,{value:Oe.useMemo(()=>({hasProvider:!0,delayRef:r,initialDelayRef:i,currentIdRef:s,timeoutMs:n,currentContextRef:a,timeout:d}),[n,d]),children:t})}function Yr(e,t={open:!1}){let{open:o}=t,n="rootStore"in e?e.rootStore:e,r=n.useState("floatingId"),i=Oe.useContext(Wa),{currentIdRef:s,delayRef:a,timeoutMs:d,initialDelayRef:c,currentContextRef:l,hasProvider:f,timeout:p}=i,[m,u]=Oe.useState(!1),g=Oe.useRef(o),v=Oe.useRef(!1);return D(()=>{g.current=o},[o]),D(()=>()=>{v.current=!0},[]),D(()=>{function _(){v.current||u(!1),l.current?.setIsInstantPhase(!1),s.current=null,l.current=null,a.current=c.current,p.clear()}if(s.current&&!o&&s.current===r){if(u(!1),d){let w=r;return p.start(d,()=>{n.select("open")||s.current&&s.current!==w||_()}),()=>{(g.current||s.current!==w)&&p.clear()}}_()}},[o,r,s,a,d,c,l,p,n]),D(()=>{if(!o)return;let _=l.current,w=s.current;p.clear(),l.current={onOpenChange:n.setOpen,setIsInstantPhase:u},s.current=r,a.current={open:0,close:St(c.current,"close")},w!==null&&w!==r?(u(!0),_?.setIsInstantPhase(!0),_?.onOpenChange(!1,ee(U.none))):(u(!1),_?.setIsInstantPhase(!1))},[o,r,n,s,a,c,l,p]),D(()=>()=>{if(s.current===r){if(l.current=null,!g.current)return;s.current=null,Yp(a,c),p.clear()}},[l,s,a,r,c,p]),Oe.useMemo(()=>({hasProvider:f,delayRef:a,isInstantPhase:m}),[f,a,m])}function it(...e){return()=>{for(let t=0;t({x:e,y:e}),Up={left:"right",right:"left",bottom:"top",top:"bottom"};function Yo(e,t,o){return Be(e,Ht(t,o))}function at(e,t){return typeof e=="function"?e(t):e}function Ee(e){return e.split("-")[0]}function ct(e){return e.split("-")[1]}function Ln(e){return e==="x"?"y":"x"}function Uo(e){return e==="y"?"height":"width"}function De(e){let t=e[0];return t==="t"||t==="b"?"y":"x"}function Go(e){return Ln(De(e))}function Xa(e,t,o){o===void 0&&(o=!1);let n=ct(e),r=Go(e),i=Uo(r),s=r==="x"?n===(o?"end":"start")?"right":"left":n==="start"?"bottom":"top";return t.reference[i]>t.floating[i]&&(s=Vo(s)),[s,Vo(s)]}function Ka(e){let t=Vo(e);return[Nn(e),t,Nn(t)]}function Nn(e){return e.includes("start")?e.replace("start","end"):e.replace("end","start")}var Ya=["left","right"],Ua=["right","left"],Gp=["top","bottom"],Xp=["bottom","top"];function Kp(e,t,o){switch(e){case"top":case"bottom":return o?t?Ua:Ya:t?Ya:Ua;case"left":case"right":return t?Gp:Xp;default:return[]}}function qa(e,t,o,n){let r=ct(e),i=Kp(Ee(e),o==="start",n);return r&&(i=i.map(s=>s+"-"+r),t&&(i=i.concat(i.map(Nn)))),i}function Vo(e){let t=Ee(e);return Up[t]+e.slice(t.length)}function qp(e){return{top:0,right:0,bottom:0,left:0,...e}}function In(e){return typeof e!="number"?qp(e):{top:e,right:e,bottom:e,left:e}}function qt(e){let{x:t,y:o,width:n,height:r}=e;return{width:n,height:r,top:o,left:t,right:t+n,bottom:o+r,x:t,y:o}}function Et(e,t,o=!0){return e.filter(r=>r.parentId===t).flatMap(r=>[...!o||r.context?.open?[r]:[],...Et(e,r.id,o)])}function go(e){return`data-base-ui-${e}`}var Ue=h(z(),1),Ja=h(Mt(),1);var Za={style:{transition:"none"}};var Zp="data-base-ui-swipe-ignore",Qp="data-swipe-ignore",ww=`[${Zp}]`,vw=`[${Qp}]`;var Qa={fallbackAxisSide:"end"};var $a=h(Q(),1),Jp=Ue.createContext(null),$p=()=>Ue.useContext(Jp),em=go("portal");function Ur(e={}){let{ref:t,container:o,componentProps:n=be,elementProps:r}=e,i=Lt(),a=$p()?.portalNode,[d,c]=Ue.useState(null),[l,f]=Ue.useState(null),p=Y(v=>{v!==null&&f(v)}),m=Ue.useRef(null);D(()=>{if(o===null){m.current&&(m.current=null,f(null),c(null));return}if(i==null)return;let v=(o&&(Rn(o)?o:o.current))??a??document.body;if(v==null){m.current&&(m.current=null,f(null),c(null));return}m.current!==v&&(m.current=v,f(null),c(v))},[o,a,i]);let u=Ce("div",n,{ref:[t,p],props:[{id:i,[em]:""},r]});return{portalNode:l,portalSubtree:d&&u?Ja.createPortal(u,d):null}}var Zt=h(z(),1);function ec(){let e=new Map;return{emit(t,o){e.get(t)?.forEach(n=>n(o))},on(t,o){e.has(t)||e.set(t,new Set),e.get(t).add(o)},off(t,o){e.get(t)?.delete(o)}}}var tm=h(Q(),1),om=Zt.createContext(null),nm=Zt.createContext(null),bo=()=>Zt.useContext(om)?.id||null,Dt=e=>{let t=Zt.useContext(nm);return e??t};var je=h(z(),1);function rm(e,t){let o=null,n=null,r=!1;return{contextElement:e||void 0,getBoundingClientRect(){let i=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},s=t.axis==="x"||t.axis==="both",a=t.axis==="y"||t.axis==="both",d=["mouseenter","mousemove"].includes(t.dataRef.current.openEvent?.type||"")&&t.pointerType!=="touch",c=i.width,l=i.height,f=i.x,p=i.y;return o==null&&t.x&&s&&(o=i.x-t.x),n==null&&t.y&&a&&(n=i.y-t.y),f-=o||0,p-=n||0,c=0,l=0,!r||d?(c=t.axis==="y"?i.width:0,l=t.axis==="x"?i.height:0,f=s&&t.x!=null?t.x:f,p=a&&t.y!=null?t.y:p):r&&!d&&(l=t.axis==="x"?i.height:l,c=t.axis==="y"?i.width:c),r=!0,{width:c,height:l,x:f,y:p,top:p,right:f+c,bottom:p+l,left:f}}}}function tc(e){return e!=null&&e.clientX!=null}function Gr(e,t={}){let{enabled:o=!0,axis:n="both"}=t,r="rootStore"in e?e.rootStore:e,i=r.useState("open"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.context.dataRef,c=je.useRef(!1),l=je.useRef(null),[f,p]=je.useState(),[m,u]=je.useState([]),g=Y(b=>{r.set("positionReference",b)}),v=Y((b,S,x)=>{c.current||d.current.openEvent&&!tc(d.current.openEvent)||r.set("positionReference",rm(x??a,{x:b,y:S,axis:n,dataRef:d,pointerType:f}))}),_=Y(b=>{i?l.current||(v(b.clientX,b.clientY,b.currentTarget),u([])):v(b.clientX,b.clientY,b.currentTarget)}),w=Rt(f)?s:i;je.useEffect(()=>{if(!o){g(a);return}if(!w)return;function b(){l.current?.(),l.current=null}let S=ge(s);function x(E){let T=Me(E);ie(s,T)?b():v(E.clientX,E.clientY)}return!d.current.openEvent||tc(d.current.openEvent)?l.current=re(S,"mousemove",x):g(a),b},[w,o,s,d,a,r,v,g,m]),je.useEffect(()=>()=>{r.set("positionReference",null)},[r]),je.useEffect(()=>{o&&!s&&(c.current=!1)},[o,s]),je.useEffect(()=>{!o&&i&&(c.current=!0)},[o,i]);let y=je.useMemo(()=>{function b(S){p(S.pointerType)}return{onPointerDown:b,onPointerEnter:b,onMouseMove:_,onMouseEnter:_}},[_]);return je.useMemo(()=>o?{reference:y,trigger:y}:{},[o,y])}var Fe=h(z(),1);function im(){return!1}function sm(e){return{escapeKey:typeof e=="boolean"?e:e?.escapeKey??!1,outsidePress:typeof e=="boolean"?e:e?.outsidePress??!0}}function Xr(e,t={}){let{enabled:o=!0,escapeKey:n=!0,outsidePress:r=!0,outsidePressEvent:i="sloppy",referencePress:s=im,bubbles:a,externalTree:d}=t,c="rootStore"in e?e.rootStore:e,l=c.useState("open"),f=c.useState("floatingElement"),{dataRef:p}=c.context,m=Dt(d),u=Y(typeof r=="function"?r:()=>!1),g=typeof r=="function"?u:r,v=g!==!1,_=Y(()=>i),{escapeKey:w,outsidePress:y}=sm(a),b=Fe.useRef(!1),S=Fe.useRef(!1),x=Fe.useRef(!1),E=Fe.useRef(!1),T=Fe.useRef(""),k=Fe.useRef(null),C=rt(),j=rt(),A=Y(()=>{j.clear(),p.current.insideReactTree=!1}),L=Y(W=>{let oe=p.current.floatingContext?.nodeId;return(m?Et(m.nodesRef.current,oe):[]).some(se=>se.context?.open&&!se.context.dataRef.current[W])}),I=Y(W=>An(W,c.select("floatingElement"))||An(W,c.select("domReferenceElement"))),R=Y(W=>{s()&&c.setOpen(!1,ee(U.triggerPress,W.nativeEvent))}),N=Y(W=>{if(!l||!o||!n||W.key!=="Escape"||E.current||!w&&L("__escapeKeyBubbles"))return;let oe=Ba(W)?W.nativeEvent:W,te=ee(U.escapeKey,oe);c.setOpen(!1,te),te.isCanceled||W.preventDefault(),!w&&!te.isPropagationAllowed&&W.stopPropagation()}),H=Y(()=>{p.current.insideReactTree=!0,j.start(0,A)}),P=Y(W=>{if(!l||!o||W.button!==0)return;let oe=Me(W.nativeEvent);ie(c.select("floatingElement"),oe)&&(b.current||(b.current=!0,S.current=!1))}),O=Y(W=>{!l||!o||(W.defaultPrevented||W.nativeEvent.defaultPrevented)&&b.current&&(S.current=!0)});Fe.useEffect(()=>{if(!l||!o)return;p.current.__escapeKeyBubbles=w,p.current.__outsidePressBubbles=y;let W=new Ye,oe=new Ye;function te(){W.clear(),E.current=!0}function se(){W.start(xt.engine.webkit?5:0,()=>{E.current=!1})}function G(){x.current=!0,oe.start(0,()=>{x.current=!1})}function K(){b.current=!1,S.current=!1}function J(){let B=T.current,F=B==="pen"||!B?"mouse":B,he=_(),ke=typeof he=="function"?he():he;return typeof ke=="string"?ke:ke[F]}function ne(B){let F=J();return F==="intentional"&&B.type!=="click"||F==="sloppy"&&B.type==="click"}function me(B){let F=p.current.floatingContext?.nodeId,he=m&&Et(m.nodesRef.current,F).some(ke=>An(B,ke.context?.elements.floating));return I(B)||he}function le(B){if(ne(B)){B.type!=="click"&&!I(B)&&(oe.clear(),x.current=!1),A();return}if(p.current.insideReactTree){A();return}let F=Me(B),he=`[${go("inert")}]`,ke=V(F)?F.getRootNode():null,kt=Array.from((uo(ke)?ke:xe(c.select("floatingElement"))).querySelectorAll(he)),Lo=c.context.triggerElements;if(F&&(Lo.hasElement(F)||Lo.hasMatchingElement(We=>ie(We,F))))return;let _t=V(F)?F:null;for(;_t&&!nt(_t);){let We=tt(_t);if(nt(We)||!V(We))break;_t=We}if(!(kt.length&&V(F)&&!za(F)&&!ie(F,c.select("floatingElement"))&&kt.every(We=>!ie(_t,We)))){if(we(F)&&!("touches"in B)){let We=nt(F),Pt=Ae(F),Ct=/auto|scroll/,un=We||Ct.test(Pt.overflowX),fn=We||Ct.test(Pt.overflowY),pn=un&&F.clientWidth>0&&F.scrollWidth>F.clientWidth,mn=fn&&F.clientHeight>0&&F.scrollHeight>F.clientHeight,gn=Pt.direction==="rtl",ae=mn&&(gn?B.offsetX<=F.offsetWidth-F.clientWidth:B.offsetX>F.clientWidth),Ie=pn&&B.offsetY>F.clientHeight;if(ae||Ie)return}if(!me(B)){if(J()==="intentional"&&x.current){oe.clear(),x.current=!1;return}typeof g=="function"&&!g(B)||L("__outsidePressBubbles")||(c.setOpen(!1,ee(U.outsidePress,B)),A())}}}function X(B){J()!=="sloppy"||B.pointerType==="touch"||!c.select("open")||!o||I(B)||le(B)}function pe(B){if(J()!=="sloppy"||!c.select("open")||!o||I(B))return;let F=B.touches[0];F&&(k.current={startTime:Date.now(),startX:F.clientX,startY:F.clientY,dismissOnTouchEnd:!1,dismissOnMouseDown:!0},C.start(1e3,()=>{k.current&&(k.current.dismissOnTouchEnd=!1,k.current.dismissOnMouseDown=!1)}))}function ue(B,F){let he=Me(B);if(!he)return;let ke=re(he,B.type,()=>{F(B),ke()})}function vt(B){T.current="touch",ue(B,pe)}function Te(B){C.clear(),B.type==="pointerdown"&&(T.current=B.pointerType),!(B.type==="mousedown"&&k.current&&!k.current.dismissOnMouseDown)&&ue(B,F=>{F.type==="pointerdown"?X(F):le(F)})}function Ve(B){if(!b.current)return;let F=S.current;if(K(),J()==="intentional"){if(B.type==="pointercancel"){F&&G();return}if(!me(B)){if(F){G();return}typeof g=="function"&&!g(B)||(oe.clear(),x.current=!0,A())}}}function Ke(B){if(J()!=="sloppy"||!k.current||I(B))return;let F=B.touches[0];if(!F)return;let he=Math.abs(F.clientX-k.current.startX),ke=Math.abs(F.clientY-k.current.startY),kt=Math.sqrt(he*he+ke*ke);kt>5&&(k.current.dismissOnTouchEnd=!0),kt>10&&(le(B),C.clear(),k.current=null)}function He(B){ue(B,Ke)}function no(B){J()!=="sloppy"||!k.current||I(B)||(k.current.dismissOnTouchEnd&&le(B),C.clear(),k.current=null)}function dn(B){ue(B,no)}let _e=xe(f),ro=it(n&&it(re(_e,"keydown",N),re(_e,"compositionstart",te),re(_e,"compositionend",se)),v&&it(re(_e,"click",Te,!0),re(_e,"pointerdown",Te,!0),re(_e,"pointerup",Ve,!0),re(_e,"pointercancel",Ve,!0),re(_e,"mousedown",Te,!0),re(_e,"mouseup",Ve,!0),re(_e,"touchstart",vt,!0),re(_e,"touchmove",He,!0),re(_e,"touchend",dn,!0)));return()=>{ro(),W.clear(),oe.clear(),K(),x.current=!1}},[p,f,n,v,g,l,o,w,y,N,A,_,L,I,m,c,C]),Fe.useEffect(A,[g,A]);let M=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:R,onClick:R}),[N,R]),Z=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:O,onMouseDown:O,onClickCapture:H,onMouseDownCapture(W){H(),P(W)},onPointerDownCapture(W){H(),P(W)},onMouseUpCapture:H,onTouchEndCapture:H,onTouchMoveCapture:H}),[N,H,P,O]);return Fe.useMemo(()=>o?{reference:M,floating:Z,trigger:M}:{},[o,M,Z])}var Ne=h(z(),1);function oc(e,t,o){let{reference:n,floating:r}=e,i=De(t),s=Go(t),a=Uo(s),d=Ee(t),c=i==="y",l=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2,p=n[a]/2-r[a]/2,m;switch(d){case"top":m={x:l,y:n.y-r.height};break;case"bottom":m={x:l,y:n.y+n.height};break;case"right":m={x:n.x+n.width,y:f};break;case"left":m={x:n.x-r.width,y:f};break;default:m={x:n.x,y:n.y}}switch(ct(t)){case"start":m[s]-=p*(o&&c?-1:1);break;case"end":m[s]+=p*(o&&c?-1:1);break}return m}async function ic(e,t){var o;t===void 0&&(t={});let{x:n,y:r,platform:i,rects:s,elements:a,strategy:d}=e,{boundary:c="clippingAncestors",rootBoundary:l="viewport",elementContext:f="floating",altBoundary:p=!1,padding:m=0}=at(t,e),u=In(m),v=a[p?f==="floating"?"reference":"floating":f],_=qt(await i.getClippingRect({element:(o=await(i.isElement==null?void 0:i.isElement(v)))==null||o?v:v.contextElement||await(i.getDocumentElement==null?void 0:i.getDocumentElement(a.floating)),boundary:c,rootBoundary:l,strategy:d})),w=f==="floating"?{x:n,y:r,width:s.floating.width,height:s.floating.height}:s.reference,y=await(i.getOffsetParent==null?void 0:i.getOffsetParent(a.floating)),b=await(i.isElement==null?void 0:i.isElement(y))?await(i.getScale==null?void 0:i.getScale(y))||{x:1,y:1}:{x:1,y:1},S=qt(i.convertOffsetParentRelativeRectToViewportRelativeRect?await i.convertOffsetParentRelativeRectToViewportRelativeRect({elements:a,rect:w,offsetParent:y,strategy:d}):w);return{top:(_.top-S.top+u.top)/b.y,bottom:(S.bottom-_.bottom+u.bottom)/b.y,left:(_.left-S.left+u.left)/b.x,right:(S.right-_.right+u.right)/b.x}}var am=50,sc=async(e,t,o)=>{let{placement:n="bottom",strategy:r="absolute",middleware:i=[],platform:s}=o,a=s.detectOverflow?s:{...s,detectOverflow:ic},d=await(s.isRTL==null?void 0:s.isRTL(t)),c=await s.getElementRects({reference:e,floating:t,strategy:r}),{x:l,y:f}=oc(c,n,d),p=n,m=0,u={};for(let g=0;gI<=0)){var j,A;let I=(((j=i.flip)==null?void 0:j.index)||0)+1,R=E[I];if(R&&(!(f==="alignment"?w!==De(R):!1)||C.every(P=>De(P.placement)===w?P.overflows[0]>0:!0)))return{data:{index:I,overflows:C},reset:{placement:R}};let N=(A=C.filter(H=>H.overflows[0]<=0).sort((H,P)=>H.overflows[1]-P.overflows[1])[0])==null?void 0:A.placement;if(!N)switch(m){case"bestFit":{var L;let H=(L=C.filter(P=>{if(x){let O=De(P.placement);return O===w||O==="y"}return!0}).map(P=>[P.placement,P.overflows.filter(O=>O>0).reduce((O,M)=>O+M,0)]).sort((P,O)=>P[1]-O[1])[0])==null?void 0:L[0];H&&(N=H);break}case"initialPlacement":N=a;break}if(r!==N)return{reset:{placement:N}}}return{}}}};function nc(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function rc(e){return Ga.some(t=>e[t]>=0)}var cc=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){let{rects:o,platform:n}=t,{strategy:r="referenceHidden",...i}=at(e,t);switch(r){case"referenceHidden":{let s=await n.detectOverflow(t,{...i,elementContext:"reference"}),a=nc(s,o.reference);return{data:{referenceHiddenOffsets:a,referenceHidden:rc(a)}}}case"escaped":{let s=await n.detectOverflow(t,{...i,altBoundary:!0}),a=nc(s,o.floating);return{data:{escapedOffsets:a,escaped:rc(a)}}}default:return{}}}}};var lc=new Set(["left","top"]);async function cm(e,t){let{placement:o,platform:n,elements:r}=e,i=await(n.isRTL==null?void 0:n.isRTL(r.floating)),s=Ee(o),a=ct(o),d=De(o)==="y",c=lc.has(s)?-1:1,l=i&&d?-1:1,f=at(t,e),{mainAxis:p,crossAxis:m,alignmentAxis:u}=typeof f=="number"?{mainAxis:f,crossAxis:0,alignmentAxis:null}:{mainAxis:f.mainAxis||0,crossAxis:f.crossAxis||0,alignmentAxis:f.alignmentAxis};return a&&typeof u=="number"&&(m=a==="end"?u*-1:u),d?{x:m*l,y:p*c}:{x:p*c,y:m*l}}var dc=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var o,n;let{x:r,y:i,placement:s,middlewareData:a}=t,d=await cm(t,e);return s===((o=a.offset)==null?void 0:o.placement)&&(n=a.arrow)!=null&&n.alignmentOffset?{}:{x:r+d.x,y:i+d.y,data:{...d,placement:s}}}}},uc=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){let{x:o,y:n,placement:r,platform:i}=t,{mainAxis:s=!0,crossAxis:a=!1,limiter:d={fn:_=>{let{x:w,y}=_;return{x:w,y}}},...c}=at(e,t),l={x:o,y:n},f=await i.detectOverflow(t,c),p=De(Ee(r)),m=Ln(p),u=l[m],g=l[p];if(s){let _=m==="y"?"top":"left",w=m==="y"?"bottom":"right",y=u+f[_],b=u-f[w];u=Yo(y,u,b)}if(a){let _=p==="y"?"top":"left",w=p==="y"?"bottom":"right",y=g+f[_],b=g-f[w];g=Yo(y,g,b)}let v=d.fn({...t,[m]:u,[p]:g});return{...v,data:{x:v.x-o,y:v.y-n,enabled:{[m]:s,[p]:a}}}}}},fc=function(e){return e===void 0&&(e={}),{options:e,fn(t){let{x:o,y:n,placement:r,rects:i,middlewareData:s}=t,{offset:a=0,mainAxis:d=!0,crossAxis:c=!0}=at(e,t),l={x:o,y:n},f=De(r),p=Ln(f),m=l[p],u=l[f],g=at(a,t),v=typeof g=="number"?{mainAxis:g,crossAxis:0}:{mainAxis:0,crossAxis:0,...g};if(d){let y=p==="y"?"height":"width",b=i.reference[p]-i.floating[y]+v.mainAxis,S=i.reference[p]+i.reference[y]-v.mainAxis;mS&&(m=S)}if(c){var _,w;let y=p==="y"?"width":"height",b=lc.has(Ee(r)),S=i.reference[f]-i.floating[y]+(b&&((_=s.offset)==null?void 0:_[f])||0)+(b?0:v.crossAxis),x=i.reference[f]+i.reference[y]+(b?0:((w=s.offset)==null?void 0:w[f])||0)-(b?v.crossAxis:0);ux&&(u=x)}return{[p]:m,[f]:u}}}},pc=function(e){return e===void 0&&(e={}),{name:"size",options:e,async fn(t){var o,n;let{placement:r,rects:i,platform:s,elements:a}=t,{apply:d=()=>{},...c}=at(e,t),l=await s.detectOverflow(t,c),f=Ee(r),p=ct(r),m=De(r)==="y",{width:u,height:g}=i.floating,v,_;f==="top"||f==="bottom"?(v=f,_=p===(await(s.isRTL==null?void 0:s.isRTL(a.floating))?"start":"end")?"left":"right"):(_=f,v=p==="end"?"top":"bottom");let w=g-l.top-l.bottom,y=u-l.left-l.right,b=Ht(g-l[v],w),S=Ht(u-l[_],y),x=!t.middlewareData.shift,E=b,T=S;if((o=t.middlewareData.shift)!=null&&o.enabled.x&&(T=y),(n=t.middlewareData.shift)!=null&&n.enabled.y&&(E=w),x&&!p){let C=Be(l.left,0),j=Be(l.right,0),A=Be(l.top,0),L=Be(l.bottom,0);m?T=u-2*(C!==0||j!==0?C+j:Be(l.left,l.right)):E=g-2*(A!==0||L!==0?A+L:Be(l.top,l.bottom))}await d({...t,availableWidth:T,availableHeight:E});let k=await s.getDimensions(a.floating);return u!==k.width||g!==k.height?{reset:{rects:!0}}:{}}}};function hc(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n,a=zt(o)!==i||zt(n)!==s;return a&&(o=i,n=s),{width:o,height:n,$:a}}function qr(e){return V(e)?e:e.contextElement}function ho(e){let t=qr(e);if(!we(t))return st(1);let o=t.getBoundingClientRect(),{width:n,height:r,$:i}=hc(t),s=(i?zt(o.width):o.width)/n,a=(i?zt(o.height):o.height)/r;return(!s||!Number.isFinite(s))&&(s=1),(!a||!Number.isFinite(a))&&(a=1),{x:s,y:a}}var lm=st(0);function wc(e){let t=ge(e);return!En()||!t.visualViewport?lm:{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}}function dm(e,t,o){return t===void 0&&(t=!1),!o||t&&o!==ge(e)?!1:t}function Qt(e,t,o,n){t===void 0&&(t=!1),o===void 0&&(o=!1);let r=e.getBoundingClientRect(),i=qr(e),s=st(1);t&&(n?V(n)&&(s=ho(n)):s=ho(e));let a=dm(i,o,n)?wc(i):st(0),d=(r.left+a.x)/s.x,c=(r.top+a.y)/s.y,l=r.width/s.x,f=r.height/s.y;if(i){let p=ge(i),m=n&&V(n)?ge(n):n,u=p,g=Tn(u);for(;g&&n&&m!==u;){let v=ho(g),_=g.getBoundingClientRect(),w=Ae(g),y=_.left+(g.clientLeft+parseFloat(w.paddingLeft))*v.x,b=_.top+(g.clientTop+parseFloat(w.paddingTop))*v.y;d*=v.x,c*=v.y,l*=v.x,f*=v.y,d+=y,c+=b,u=ge(g),g=Tn(u)}}return qt({width:l,height:f,x:d,y:c})}function Mn(e,t){let o=jo(e).scrollLeft;return t?t.left+o:Qt(ot(e)).left+o}function vc(e,t){let o=e.getBoundingClientRect(),n=o.left+t.scrollLeft-Mn(e,o),r=o.top+t.scrollTop;return{x:n,y:r}}function um(e){let{elements:t,rect:o,offsetParent:n,strategy:r}=e,i=r==="fixed",s=ot(n),a=t?Do(t.floating):!1;if(n===s||a&&i)return o;let d={scrollLeft:0,scrollTop:0},c=st(1),l=st(0),f=we(n);if((f||!f&&!i)&&((Gt(n)!=="body"||fo(s))&&(d=jo(n)),f)){let m=Qt(n);c=ho(n),l.x=m.x+n.clientLeft,l.y=m.y+n.clientTop}let p=s&&!f&&!i?vc(s,d):st(0);return{width:o.width*c.x,height:o.height*c.y,x:o.x*c.x-d.scrollLeft*c.x+l.x+p.x,y:o.y*c.y-d.scrollTop*c.y+l.y+p.y}}function fm(e){return Array.from(e.getClientRects())}function pm(e){let t=ot(e),o=jo(e),n=e.ownerDocument.body,r=Be(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),i=Be(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-o.scrollLeft+Mn(e),a=-o.scrollTop;return Ae(n).direction==="rtl"&&(s+=Be(t.clientWidth,n.clientWidth)-r),{width:r,height:i,x:s,y:a}}var mc=25;function mm(e,t){let o=ge(e),n=ot(e),r=o.visualViewport,i=n.clientWidth,s=n.clientHeight,a=0,d=0;if(r){i=r.width,s=r.height;let l=En();(!l||l&&t==="fixed")&&(a=r.offsetLeft,d=r.offsetTop)}let c=Mn(n);if(c<=0){let l=n.ownerDocument,f=l.body,p=getComputedStyle(f),m=l.compatMode==="CSS1Compat"&&parseFloat(p.marginLeft)+parseFloat(p.marginRight)||0,u=Math.abs(n.clientWidth-f.clientWidth-m);u<=mc&&(i-=u)}else c<=mc&&(i+=c);return{width:i,height:s,x:a,y:d}}function gm(e,t){let o=Qt(e,!0,t==="fixed"),n=o.top+e.clientTop,r=o.left+e.clientLeft,i=we(e)?ho(e):st(1),s=e.clientWidth*i.x,a=e.clientHeight*i.y,d=r*i.x,c=n*i.y;return{width:s,height:a,x:d,y:c}}function gc(e,t,o){let n;if(t==="viewport")n=mm(e,o);else if(t==="document")n=pm(ot(e));else if(V(t))n=gm(t,o);else{let r=wc(e);n={x:t.x-r.x,y:t.y-r.y,width:t.width,height:t.height}}return qt(n)}function _c(e,t){let o=tt(e);return o===t||!V(o)||nt(o)?!1:Ae(o).position==="fixed"||_c(o,t)}function bm(e,t){let o=t.get(e);if(o)return o;let n=It(e,[],!1).filter(a=>V(a)&&Gt(a)!=="body"),r=null,i=Ae(e).position==="fixed",s=i?tt(e):e;for(;V(s)&&!nt(s);){let a=Ae(s),d=Sn(s);!d&&a.position==="fixed"&&(r=null),(i?!d&&!r:!d&&a.position==="static"&&!!r&&(r.position==="absolute"||r.position==="fixed")||fo(s)&&!d&&_c(e,s))?n=n.filter(l=>l!==s):r=a,s=tt(s)}return t.set(e,n),n}function hm(e){let{element:t,boundary:o,rootBoundary:n,strategy:r}=e,s=[...o==="clippingAncestors"?Do(t)?[]:bm(t,this._c):[].concat(o),n],a=gc(t,s[0],r),d=a.top,c=a.right,l=a.bottom,f=a.left;for(let p=1;p{s(!1,1e-7)},1e3)}E===1&&!xc(c,e.getBoundingClientRect())&&s(),b=!1}try{o=new IntersectionObserver(S,{...y,root:r.ownerDocument})}catch{o=new IntersectionObserver(S,y)}o.observe(e)}return s(!0),i}function Xo(e,t,o,n){n===void 0&&(n={});let{ancestorScroll:r=!0,ancestorResize:i=!0,elementResize:s=typeof ResizeObserver=="function",layoutShift:a=typeof IntersectionObserver=="function",animationFrame:d=!1}=n,c=qr(e),l=r||i?[...c?It(c):[],...t?It(t):[]]:[];l.forEach(_=>{r&&_.addEventListener("scroll",o,{passive:!0}),i&&_.addEventListener("resize",o)});let f=c&&a?xm(c,o):null,p=-1,m=null;s&&(m=new ResizeObserver(_=>{let[w]=_;w&&w.target===c&&m&&t&&(m.unobserve(t),cancelAnimationFrame(p),p=requestAnimationFrame(()=>{var y;(y=m)==null||y.observe(t)})),o()}),c&&!d&&m.observe(c),t&&m.observe(t));let u,g=d?Qt(e):null;d&&v();function v(){let _=Qt(e);g&&!xc(g,_)&&o(),g=_,u=requestAnimationFrame(v)}return o(),()=>{var _;l.forEach(w=>{r&&w.removeEventListener("scroll",o),i&&w.removeEventListener("resize",o)}),f?.(),(_=m)==null||_.disconnect(),m=null,d&&cancelAnimationFrame(u)}}var Rc=dc;var Sc=uc,Ec=ac,Tc=pc,kc=cc;var Pc=fc,Bn=(e,t,o)=>{let n=new Map,r={platform:Zr,...o},i={...r.platform,_c:n};return sc(e,t,{...r,platform:i})};var ve=h(z(),1),Ac=h(z(),1),Oc=h(Mt(),1),Sm=typeof document<"u",Em=function(){},Hn=Sm?Ac.useLayoutEffect:Em;function zn(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(typeof e=="function"&&e.toString()===t.toString())return!0;let o,n,r;if(e&&t&&typeof e=="object"){if(Array.isArray(e)){if(o=e.length,o!==t.length)return!1;for(n=o;n--!==0;)if(!zn(e[n],t[n]))return!1;return!0}if(r=Object.keys(e),o=r.length,o!==Object.keys(t).length)return!1;for(n=o;n--!==0;)if(!{}.hasOwnProperty.call(t,r[n]))return!1;for(n=o;n--!==0;){let i=r[n];if(!(i==="_owner"&&e.$$typeof)&&!zn(e[i],t[i]))return!1}return!0}return e!==e&&t!==t}function Nc(e){return typeof window>"u"?1:(e.ownerDocument.defaultView||window).devicePixelRatio||1}function Cc(e,t){let o=Nc(e);return Math.round(t*o)/o}function Qr(e){let t=ve.useRef(e);return Hn(()=>{t.current=e}),t}function Lc(e){e===void 0&&(e={});let{placement:t="bottom",strategy:o="absolute",middleware:n=[],platform:r,elements:{reference:i,floating:s}={},transform:a=!0,whileElementsMounted:d,open:c}=e,[l,f]=ve.useState({x:0,y:0,strategy:o,placement:t,middlewareData:{},isPositioned:!1}),[p,m]=ve.useState(n);zn(p,n)||m(n);let[u,g]=ve.useState(null),[v,_]=ve.useState(null),w=ve.useCallback(P=>{P!==x.current&&(x.current=P,g(P))},[]),y=ve.useCallback(P=>{P!==E.current&&(E.current=P,_(P))},[]),b=i||u,S=s||v,x=ve.useRef(null),E=ve.useRef(null),T=ve.useRef(l),k=d!=null,C=Qr(d),j=Qr(r),A=Qr(c),L=ve.useCallback(()=>{if(!x.current||!E.current)return;let P={placement:t,strategy:o,middleware:p};j.current&&(P.platform=j.current),Bn(x.current,E.current,P).then(O=>{let M={...O,isPositioned:A.current!==!1};I.current&&!zn(T.current,M)&&(T.current=M,Oc.flushSync(()=>{f(M)}))})},[p,t,o,j,A]);Hn(()=>{c===!1&&T.current.isPositioned&&(T.current.isPositioned=!1,f(P=>({...P,isPositioned:!1})))},[c]);let I=ve.useRef(!1);Hn(()=>(I.current=!0,()=>{I.current=!1}),[]),Hn(()=>{if(b&&(x.current=b),S&&(E.current=S),b&&S){if(C.current)return C.current(b,S,L);L()}},[b,S,L,C,k]);let R=ve.useMemo(()=>({reference:x,floating:E,setReference:w,setFloating:y}),[w,y]),N=ve.useMemo(()=>({reference:b,floating:S}),[b,S]),H=ve.useMemo(()=>{let P={position:o,left:0,top:0};if(!N.floating)return P;let O=Cc(N.floating,l.x),M=Cc(N.floating,l.y);return a?{...P,transform:"translate("+O+"px, "+M+"px)",...Nc(N.floating)>=1.5&&{willChange:"transform"}}:{position:o,left:O,top:M}},[o,a,N.floating,l.x,l.y]);return ve.useMemo(()=>({...l,update:L,refs:R,elements:N,floatingStyles:H}),[l,L,R,N,H])}var Jr=(e,t)=>{let o=Rc(e);return{name:o.name,fn:o.fn,options:[e,t]}},$r=(e,t)=>{let o=Sc(e);return{name:o.name,fn:o.fn,options:[e,t]}},ei=(e,t)=>({fn:Pc(e).fn,options:[e,t]}),ti=(e,t)=>{let o=Ec(e);return{name:o.name,fn:o.fn,options:[e,t]}},oi=(e,t)=>{let o=Tc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var ni=(e,t)=>{let o=kc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var _o=h(z(),1),qc=h(Mt(),1);var Xc=h(z(),1);var q=(e,t,o,n,r,i,...s)=>{if(s.length>0)throw new Error(Pe(1));let a;if(e&&t&&o&&n&&r&&i)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f),v=r(d,c,l,f);return i(p,m,u,g,v,c,l,f)};else if(e&&t&&o&&n&&r)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f);return r(p,m,u,g,c,l,f)};else if(e&&t&&o&&n)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f);return n(p,m,u,c,l,f)};else if(e&&t&&o)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f);return o(p,m,c,l,f)};else if(e&&t)a=(d,c,l,f)=>{let p=e(d,c,l,f);return t(p,c,l,f)};else if(e)a=e;else throw new Error("Missing arguments");return a};var Uc=h(z(),1),li=h(ii(),1),Gc=h(jc(),1);var Fc=h(z(),1);var si=[],ai;function Vc(){return ai}function Wc(e){si.push(e)}function ci(e){let t=(o,n)=>{let r=Se(Wm).current,i;try{ai=r;for(let s of si)s.before(r);i=e(o,n);for(let s of si)s.after(r);r.didInitialize=!0}finally{ai=void 0}return i};return t.displayName=e.displayName||e.name,t}function Yc(e){return Fc.forwardRef(ci(e))}function Wm(){return{didInitialize:!1}}var Ym=ao(19),Um=Ym?Xm:Km;function jn(e,t,o,n,r){return Um(e,t,o,n,r)}function Gm(e,t,o,n,r){let i=Uc.useCallback(()=>t(e.getSnapshot(),o,n,r),[e,t,o,n,r]);return(0,li.useSyncExternalStore)(e.subscribe,i,i)}Wc({before(e){e.syncIndex=0,e.didInitialize||(e.syncTick=1,e.syncHooks=[],e.didChangeStore=!0,e.getSnapshot=()=>{let t=!1;for(let o=0;o0&&(e.didChangeStore&&(e.didChangeStore=!1,e.subscribe=t=>{let o=new Set;for(let r of e.syncHooks)o.add(r.store);let n=[];for(let r of o)n.push(r.subscribe(t));return()=>{for(let r of n)r()}}),(0,li.useSyncExternalStore)(e.subscribe,e.getSnapshot,e.getSnapshot))}});function Xm(e,t,o,n,r){let i=Vc();if(!i)return Gm(e,t,o,n,r);let s=i.syncIndex;i.syncIndex+=1;let a;return i.didInitialize?(a=i.syncHooks[s],(a.store!==e||a.selector!==t||!Object.is(a.a1,o)||!Object.is(a.a2,n)||!Object.is(a.a3,r))&&(a.store!==e&&(i.didChangeStore=!0),a.store=e,a.selector=t,a.a1=o,a.a2=n,a.a3=r,a.value=t(e.getSnapshot(),o,n,r))):(a={store:e,selector:t,a1:o,a2:n,a3:r,value:t(e.getSnapshot(),o,n,r)},i.syncHooks.push(a)),a.value}function Km(e,t,o,n,r){return(0,Gc.useSyncExternalStoreWithSelector)(e.subscribe,e.getSnapshot,e.getSnapshot,i=>t(i,o,n,r))}var Fn=class{constructor(t){this.state=t,this.listeners=new Set,this.updateTick=0}subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)});getSnapshot=()=>this.state;setState(t){if(this.state===t)return;this.state=t,this.updateTick+=1;let o=this.updateTick;for(let n of this.listeners){if(o!==this.updateTick)return;n(t)}}update(t){for(let o in t)if(!Object.is(this.state[o],t[o])){this.setState({...this.state,...t});return}}set(t,o){Object.is(this.state[t],o)||this.setState({...this.state,[t]:o})}notifyAll(){let t={...this.state};this.setState(t)}use(t,o,n,r){return jn(this,t,o,n,r)}};var Jt=h(z(),1);var vo=class extends Fn{constructor(t,o={},n){super(t),this.context=o,this.selectors=n}useSyncedValue(t,o){Jt.useDebugValue(t);let n=this;D(()=>{n.state[t]!==o&&n.set(t,o)},[n,t,o])}useSyncedValueWithCleanup(t,o){let n=this;D(()=>(n.state[t]!==o&&n.set(t,o),()=>{n.set(t,void 0)}),[n,t,o])}useSyncedValues(t){let o=this,n=Object.values(t);D(()=>{o.update(t)},[o,...n])}useControlledProp(t,o){Jt.useDebugValue(t);let n=this,r=o!==void 0;D(()=>{r&&!Object.is(n.state[t],o)&&n.setState({...n.state,[t]:o})},[n,t,o,r])}select(t,o,n,r){let i=this.selectors[t];return i(this.state,o,n,r)}useState(t,o,n,r){return Jt.useDebugValue(t),jn(this,this.selectors[t],o,n,r)}useContextCallback(t,o){Jt.useDebugValue(t);let n=Y(o??Nt);this.context[t]=n}useStateSetter(t){let o=Jt.useRef(void 0);return o.current===void 0&&(o.current=n=>{this.set(t,n)}),o.current}observe(t,o){let n;typeof t=="function"?n=t:n=this.selectors[t];let r=n(this.state);return o(r,r,this),this.subscribe(i=>{let s=n(i);if(!Object.is(r,s)){let a=r;r=s,o(s,a,this)}})}};var qm={open:q(e=>e.open),transitionStatus:q(e=>e.transitionStatus),domReferenceElement:q(e=>e.domReferenceElement),referenceElement:q(e=>e.positionReference??e.referenceElement),floatingElement:q(e=>e.floatingElement),floatingId:q(e=>e.floatingId)},pt=class extends vo{constructor(t){let{syncOnly:o,nested:n,onOpenChange:r,triggerElements:i,...s}=t;super({...s,positionReference:s.referenceElement,domReferenceElement:s.referenceElement},{onOpenChange:r,dataRef:{current:{}},events:ec(),nested:n,triggerElements:i},qm),this.syncOnly=o}syncOpenEvent=(t,o)=>{(!t||!this.state.open||o!=null&&Ha(o))&&(this.context.dataRef.current.openEvent=t?o:void 0)};dispatchOpenChange=(t,o)=>{this.syncOpenEvent(t,o.event);let n={open:t,reason:o.reason,nativeEvent:o.event,nested:this.context.nested,triggerElement:o.trigger};this.context.events.emit("openchange",n)};setOpen=(t,o)=>{if(this.syncOnly){this.context.onOpenChange?.(t,o);return}this.dispatchOpenChange(t,o),this.context.onOpenChange?.(t,o)}};function Kc(e){let{popupStore:t,treatPopupAsFloatingElement:o=!1,floatingRootContext:n,floatingId:r,nested:i,onOpenChange:s}=e,a=t.useState("open"),d=t.useState("activeTriggerElement"),c=t.useState(o?"popupElement":"positionerElement"),l=t.context.triggerElements,f=s,p=Xc.useRef(null);n===void 0&&p.current===null&&(p.current=new pt({open:a,transitionStatus:void 0,referenceElement:d,floatingElement:c,triggerElements:l,onOpenChange:f,floatingId:r,syncOnly:!0,nested:i}));let m=n??p.current;return t.useSyncedValue("floatingId",r),D(()=>{let u={open:a,floatingId:r,referenceElement:d,floatingElement:c};V(d)&&(u.domReferenceElement=d),m.state.positionReference===m.state.referenceElement&&(u.positionReference=d),m.update(u)},[a,r,d,c,m]),m.context.onOpenChange=f,m.context.nested=i,m}var Zc={tabIndex:-1,[Dr]:""};function Qc(e,t,o=!1){let n=Lt(),r=bo()!=null,i=_o.useRef(null);e===void 0&&i.current===null&&(i.current=t(n,r));let s=e??i.current;return Kc({popupStore:s,treatPopupAsFloatingElement:o,floatingRootContext:s.state.floatingRootContext,floatingId:n,nested:r,onOpenChange:s.setOpen}),{store:s,internalStore:i.current}}function Zm(e,t){let o=_o.useRef(null),n=_o.useRef(null);return _o.useCallback(r=>{if(e===void 0)return;let i=!1;if(o.current!==null){let s=o.current,a=n.current,d=t.context.triggerElements.getById(s);a&&d===a&&(t.context.triggerElements.delete(s),i=!0),o.current=null,n.current=null}if(r!==null&&(o.current=e,n.current=r,t.context.triggerElements.add(e,r),i=!0),i){let s=t.context.triggerElements.size;t.select("open")&&t.state.triggerCount!==s&&t.set("triggerCount",s)}},[t,e])}function Qm(e,t,o,n=!1){t?e.preventUnmountingOnClose=!1:n&&(e.preventUnmountingOnClose=!0);let r=o?.id??null;(r||t)&&(e.activeTriggerId=r,e.activeTriggerElement=o??null)}function Jm(e){let t=!1;return e.preventUnmountOnClose=()=>{t=!0},()=>t}function Jc(e,t,o,n={}){let r=o.reason,i=r===U.triggerHover,s=t&&r===U.triggerFocus,a=!t&&(r===U.triggerPress||r===U.escapeKey),d=Jm(o);if(e.context.onOpenChange?.(t,o),o.isCanceled)return;n.onBeforeDispatch?.(),e.state.floatingRootContext.dispatchOpenChange(t,o);let c=()=>{let l={...n.extraState,open:t};s?l.instantType="focus":a?l.instantType="dismiss":i&&(l.instantType=void 0),Qm(l,t,o.trigger,d()),e.update(l)};i?qc.flushSync(c):c()}function $c(e,t,o,n){Oa(()=>{t===void 0&&e.state.open===!1&&o&&(e.state={...e.state,open:!0,activeTriggerId:n,preventUnmountingOnClose:!1})})}function el(e,t,o,n){let r=o.useState("isMountedByTrigger",e),i=Zm(e,o),s=Y(a=>{if(i(a),!a)return;let d=o.select("open"),c=o.select("activeTriggerId");if(c===e){o.update({activeTriggerElement:a,...d?n:null});return}c==null&&d&&o.update({activeTriggerId:e,activeTriggerElement:a,...n})});return D(()=>{r&&o.update({activeTriggerElement:t.current,...n})},[r,o,t,...Object.values(n)]),{registerTrigger:s,isMountedByThisTrigger:r}}function tl(e,t={}){let{closeOnActiveTriggerUnmount:o=!1}=t,n=e.useState("open"),r=e.useState("triggerCount");D(()=>{if(!n){e.state.triggerCount!==0&&e.set("triggerCount",0);return}let i=e.context.triggerElements.size,s={};e.state.triggerCount!==i&&(s.triggerCount=i);let a=e.select("activeTriggerId"),d=null;if(a){let c=e.context.triggerElements.getById(a);c?c!==e.state.activeTriggerElement&&(s.activeTriggerElement=c):d=a}if(!d&&!a&&i===1){let c=e.context.triggerElements.entries().next();if(!c.done){let[l,f]=c.value;s.activeTriggerId=l,s.activeTriggerElement=f}}(s.triggerCount!==void 0||s.activeTriggerId!==void 0||s.activeTriggerElement!==void 0)&&e.update(s),d&&o&&queueMicrotask(()=>{if(e.select("open")&&e.select("activeTriggerId")===d&&!e.context.triggerElements.getById(d)){let c=ee(U.none);e.setOpen(!1,c),c.isCanceled||e.update({activeTriggerId:null,activeTriggerElement:null})}})},[n,e,r,o])}function ol(e,t,o){let{mounted:n,setMounted:r,transitionStatus:i}=ha(e),s=t.useState("preventUnmountingOnClose"),a=e?!1:s;t.useSyncedValues({mounted:n,transitionStatus:i,preventUnmountingOnClose:a});let d=Y(()=>{r(!1),t.update({activeTriggerId:null,activeTriggerElement:null,mounted:!1,preventUnmountingOnClose:!1}),o?.(),t.context.onOpenChangeComplete?.(!1)});return Pn({enabled:n&&!e&&!a,open:e,ref:t.context.popupRef,onComplete(){e||d()}}),{forceUnmount:d,transitionStatus:i}}function nl(e,t){e.useSyncedValues(t),D(()=>()=>{e.update({activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be})},[e])}var jt=class{constructor(){this.elementsSet=new Set,this.idMap=new Map}add(t,o){let n=this.idMap.get(t);n!==o&&(n!==void 0&&this.elementsSet.delete(n),this.elementsSet.add(o),this.idMap.set(t,o))}delete(t){let o=this.idMap.get(t);o&&(this.elementsSet.delete(o),this.idMap.delete(t))}hasElement(t){return this.elementsSet.has(t)}hasMatchingElement(t){for(let o of this.elementsSet)if(t(o))return!0;return!1}getById(t){return this.idMap.get(t)}entries(){return this.idMap.entries()}elements(){return this.elementsSet.values()}get size(){return this.idMap.size}};function rl(){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:new jt,floatingId:void 0,syncOnly:!1,nested:!1,onOpenChange:void 0})}function sl(){return{open:!1,openProp:void 0,mounted:!1,transitionStatus:void 0,floatingRootContext:rl(),floatingId:void 0,triggerCount:0,preventUnmountingOnClose:!1,payload:void 0,activeTriggerId:null,activeTriggerElement:null,triggerIdProp:void 0,popupElement:null,positionerElement:null,activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be}}function al(e,t,o=!1){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:e,floatingId:t,syncOnly:!0,nested:o,onOpenChange:void 0})}var Ko=q(e=>e.triggerIdProp??e.activeTriggerId),di=q(e=>e.openProp??e.open),il=q(e=>(e.popupElement?.id??e.floatingId)||void 0);function cl(e,t){return t!==void 0&&di(e)&&Ko(e)===t}function $m(e,t){return cl(e,t)?!0:t!==void 0&&di(e)&&Ko(e)==null&&e.triggerCount===1}var ll={open:di,mounted:q(e=>e.mounted),transitionStatus:q(e=>e.transitionStatus),floatingRootContext:q(e=>e.floatingRootContext),triggerCount:q(e=>e.triggerCount),preventUnmountingOnClose:q(e=>e.preventUnmountingOnClose),payload:q(e=>e.payload),activeTriggerId:Ko,activeTriggerElement:q(e=>e.mounted?e.activeTriggerElement:null),popupId:il,isTriggerActive:q((e,t)=>t!==void 0&&Ko(e)===t),isOpenedByTrigger:q((e,t)=>cl(e,t)),isMountedByTrigger:q((e,t)=>t!==void 0&&Ko(e)===t&&e.mounted),triggerProps:q((e,t)=>t?e.activeTriggerProps:e.inactiveTriggerProps),triggerPopupId:q((e,t)=>$m(e,t)?il(e):void 0),popupProps:q(e=>e.popupProps),popupElement:q(e=>e.popupElement),positionerElement:q(e=>e.positionerElement)};function dl(e){let{open:t=!1,onOpenChange:o,elements:n={}}=e,r=Lt(),i=bo()!=null,s=Se(()=>new pt({open:t,transitionStatus:void 0,onOpenChange:o,referenceElement:n.reference??null,floatingElement:n.floating??null,triggerElements:new jt,floatingId:r,syncOnly:!1,nested:i})).current;return D(()=>{let a={open:t,floatingId:r};n.reference!==void 0&&(a.referenceElement=n.reference,a.domReferenceElement=V(n.reference)?n.reference:null),n.floating!==void 0&&(a.floatingElement=n.floating),s.update(a)},[t,r,n.reference,n.floating,s]),s.context.onOpenChange=o,s.context.nested=i,s}function ui(e={}){let{nodeId:t,externalTree:o}=e,n=dl(e),r=e.rootContext||n,i=r.useState("referenceElement"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.useState("open"),c=r.useState("floatingId"),[l,f]=Ne.useState(null),[p,m]=Ne.useState(void 0),[u,g]=Ne.useState(void 0),v=Ne.useRef(null),_=Dt(o),w=Ne.useMemo(()=>({reference:i,floating:s,domReference:a}),[i,s,a]),y=Lc({...e,elements:{...w,...l&&{reference:l}}}),b=V(p)?p:null,S=u===void 0?r.state.floatingElement:u;r.useSyncedValue("referenceElement",p??null),r.useSyncedValue("domReferenceElement",p===void 0?a:b),r.useSyncedValue("floatingElement",S);let x=Ne.useCallback(A=>{let L=V(A)?{getBoundingClientRect:()=>A.getBoundingClientRect(),getClientRects:()=>A.getClientRects(),contextElement:A}:A;f(L),y.refs.setReference(L)},[y.refs]),E=Ne.useCallback(A=>{(V(A)||A===null)&&(v.current=A,m(A)),(V(y.refs.reference.current)||y.refs.reference.current===null||A!==null&&!V(A))&&y.refs.setReference(A)},[y.refs,m]),T=Ne.useCallback(A=>{g(A),y.refs.setFloating(A)},[y.refs]),k=Ne.useMemo(()=>({...y.refs,setReference:E,setFloating:T,setPositionReference:x,domReference:v}),[y.refs,E,T,x]),C=Ne.useMemo(()=>({...y.elements,domReference:a}),[y.elements,a]),j=Ne.useMemo(()=>({...y,dataRef:r.context.dataRef,open:d,onOpenChange:r.setOpen,events:r.context.events,floatingId:c,refs:k,elements:C,nodeId:t,rootStore:r}),[y,k,C,t,r,d,c]);return D(()=>{a&&(v.current=a)},[a]),D(()=>{r.context.dataRef.current.floatingContext=j;let A=_?.nodesRef.current.find(L=>L.id===t);A&&(A.context=j)}),Ne.useMemo(()=>({...y,context:j,refs:k,elements:C,rootStore:r}),[y,k,C,j,r])}var mt=h(z(),1);var fi=xt.os.mac&&xt.engine.webkit;function pi(e,t={}){let{enabled:o=!0,delay:n}=t,r="rootStore"in e?e.rootStore:e,{events:i,dataRef:s}=r.context,a=mt.useRef(!1),d=mt.useRef(null),c=mt.useRef(!0),l=rt();mt.useEffect(()=>{let p=r.select("domReferenceElement");if(!o)return;let m=ge(p);function u(){let _=r.select("domReferenceElement");!r.select("open")&&we(_)&&_===Cn(xe(_))&&(a.current=!0)}function g(){c.current=!0}function v(){c.current=!1}return it(re(m,"blur",u),fi&&re(m,"keydown",g,!0),fi&&re(m,"pointerdown",v,!0))},[r,o]),mt.useEffect(()=>{if(!o)return;function p(m){if(m.reason===U.triggerPress||m.reason===U.escapeKey){let u=r.select("domReferenceElement");V(u)&&(d.current=u,a.current=!0)}}return i.on("openchange",p),()=>{i.off("openchange",p)}},[i,o,r]);let f=mt.useMemo(()=>{function p(){a.current=!1,d.current=null}return{onMouseLeave(){p()},onFocus(m){let u=m.currentTarget;if(a.current){if(d.current===u)return;p()}let g=Me(m.nativeEvent);if(V(g)){if(fi&&!m.relatedTarget){if(!c.current&&!Da(g))return}else if(!ja(g))return}let v=Bt(m.relatedTarget,r.context.triggerElements),{nativeEvent:_,currentTarget:w}=m,y=typeof n=="function"?n():n;if(r.select("open")&&v||y===0||y===void 0){r.setOpen(!0,ee(U.triggerFocus,_,w));return}l.start(y,()=>{a.current||r.setOpen(!0,ee(U.triggerFocus,_,w))})},onBlur(m){p();let u=m.relatedTarget,g=m.nativeEvent,v=V(u)&&u.hasAttribute(go("focus-guard"))&&u.getAttribute("data-type")==="outside";l.start(0,()=>{let _=r.select("domReferenceElement"),w=Cn(xe(_));!u&&w===_||ie(s.current.floatingContext?.refs.floating.current,w)||ie(_,w)||v||Bt(u??w,r.context.triggerElements)||r.setOpen(!1,ee(U.triggerFocus,g))})}}},[s,n,r,l]);return mt.useMemo(()=>o?{reference:f,trigger:f}:{},[o,f])}var gi=h(z(),1);var mi=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new Ye,this.restTimeout=new Ye,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Vn=new WeakMap;function yo(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Vn.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty("pointer-events"),e.pointerEventsReferenceElement?.style.removeProperty("pointer-events"),e.pointerEventsFloatingElement?.style.removeProperty("pointer-events"),Vn.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Wn(e,t){let{scopeElement:o,referenceElement:n,floatingElement:r}=t,i=Vn.get(o);i&&i!==e&&yo(i),yo(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=o,e.pointerEventsReferenceElement=n,e.pointerEventsFloatingElement=r,Vn.set(o,e),o.style.pointerEvents="none",n.style.pointerEvents="auto",r.style.pointerEvents="auto"}function xo(e){let t=e.context.dataRef.current,o=Se(()=>t.hoverInteractionState??mi.create()).current;return t.hoverInteractionState||(t.hoverInteractionState=o),co(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function bi(e,t={}){let{enabled:o=!0,closeDelay:n=0,nodeId:r}=t,i="rootStore"in e?e.rootStore:e,s=i.useState("open"),a=i.useState("floatingElement"),d=i.useState("domReferenceElement"),{dataRef:c}=i.context,l=Dt(),f=bo(),p=xo(i),m=rt(),u=Y(()=>On(c.current.openEvent?.type,p.interactedInside)),g=Y(()=>Fa(c.current.openEvent?.type)),v=Y(()=>{yo(p)});D(()=>{s||(p.pointerType=void 0,p.restTimeoutPending=!1,p.interactedInside=!1,v())},[s,p,v]),gi.useEffect(()=>v,[v]),D(()=>{if(o&&s&&p.handleCloseOptions?.blockPointerEvents&&g()&&V(d)&&a){let _=d,w=a,y=xe(a),b=l?.nodesRef.current.find(T=>T.id===f)?.context?.elements.floating;b&&(b.style.pointerEvents="");let S=p.pointerEventsScopeElement!==w?p.pointerEventsScopeElement:null,x=b!==w?b:null,E=p.handleCloseOptions?.getScope?.()??S??x??_.closest("[data-rootownerid]")??y.body;return Wn(p,{scopeElement:E,referenceElement:_,floatingElement:w}),()=>{v()}}},[o,s,d,a,p,g,l,f,v]),gi.useEffect(()=>{if(!o)return;function _(){return!!(l&&f&&Et(l.nodesRef.current,f).length>0)}function w(T){let k=St(n,"close",p.pointerType),C=()=>{i.setOpen(!1,ee(U.triggerHover,T)),l?.events.emit("floating.closed",T)};k?p.openChangeTimeout.start(k,C):(p.openChangeTimeout.clear(),C())}function y(T){let k=Me(T);if(!Fr(k)){p.interactedInside=!1;return}p.interactedInside=k?.closest("[aria-haspopup]")!=null}function b(){p.openChangeTimeout.clear(),m.clear(),l?.events.off("floating.closed",x),v()}function S(T){if(_()&&l){l.events.on("floating.closed",x);return}if(Bt(T.relatedTarget,i.context.triggerElements))return;let k=c.current.floatingContext?.nodeId??r,C=T.relatedTarget;if(!(l&&k&&V(C)&&Et(l.nodesRef.current,k,!1).some(A=>ie(A.context?.elements.floating,C)))){if(p.handler){p.handler(T);return}v(),g()&&!u()&&w(T)}}function x(T){!l||!f||_()||m.start(0,()=>{l.events.off("floating.closed",x),i.setOpen(!1,ee(U.triggerHover,T)),l.events.emit("floating.closed",T)})}let E=a;return it(E&&re(E,"mouseenter",b),E&&re(E,"mouseleave",S),E&&re(E,"pointerdown",y,!0),()=>{l?.events.off("floating.closed",x)})},[o,a,i,c,n,r,g,u,v,p,l,f,m])}var Ft=h(z(),1),ul=h(Mt(),1);var eg={current:null};function hi(e,t={}){let{enabled:o=!0,delay:n=0,handleClose:r=null,mouseOnly:i=!1,restMs:s=0,move:a=!0,triggerElementRef:d=eg,externalTree:c,isActiveTrigger:l=!0,getHandleCloseContext:f,isClosing:p,shouldOpen:m}=t,u="rootStore"in e?e.rootStore:e,{dataRef:g,events:v}=u.context,_=Dt(c),w=xo(u),y=Ft.useRef(!1),b=ze(r),S=ze(n),x=ze(s),E=ze(o),T=ze(m),k=ze(p),C=Y(()=>On(g.current.openEvent?.type,w.interactedInside)),j=Y(()=>T.current?.()!==!1),A=Y((R,N,H)=>{let P=u.context.triggerElements;if(P.hasElement(N))return!R||!ie(R,N);if(!V(H))return!1;let O=H;return P.hasMatchingElement(M=>ie(M,O))&&(!R||!ie(R,O))}),L=Y(()=>{if(!w.handler)return;xe(u.select("domReferenceElement")).removeEventListener("mousemove",w.handler),w.handler=void 0}),I=Y(()=>{yo(w)});return l&&(w.handleCloseOptions=b.current?.__options),Ft.useEffect(()=>L,[L]),Ft.useEffect(()=>{if(!o)return;function R(N){N.open?y.current=!1:(y.current=N.reason===U.triggerHover,L(),w.openChangeTimeout.clear(),w.restTimeout.clear(),w.blockMouseMove=!0,w.restTimeoutPending=!1)}return v.on("openchange",R),()=>{v.off("openchange",R)}},[o,v,w,L]),Ft.useEffect(()=>{if(!o)return;function R(O,M=!0){let Z=St(S.current,"close",w.pointerType);Z?w.openChangeTimeout.start(Z,()=>{u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O)}):M&&(w.openChangeTimeout.clear(),u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O))}let N=d.current??(l?u.select("domReferenceElement"):null);if(!V(N))return;function H(O){if(w.openChangeTimeout.clear(),w.blockMouseMove=!1,i&&!Rt(w.pointerType))return;let M=Vr(x.current),Z=St(S.current,"open",w.pointerType),W=Me(O),oe=O.currentTarget??null,te=u.select("domReferenceElement"),se=oe;if(V(W)&&!u.context.triggerElements.hasElement(W)){for(let ue of u.context.triggerElements.elements())if(ie(ue,W)){se=ue;break}}V(oe)&&V(te)&&!u.context.triggerElements.hasElement(oe)&&ie(oe,te)&&(se=te);let G=se==null?!1:A(te,se,W),K=u.select("open"),J=k.current?.()??u.select("transitionStatus")==="ending",ne=!K&&J&&y.current,me=!G&&V(se)&&V(te)&&ie(te,se)&&ne,le=M>0&&!Z,X=G&&(K||ne)||me,pe=!K||G;if(X){j()&&u.setOpen(!0,ee(U.triggerHover,O,se));return}le||(Z?w.openChangeTimeout.start(Z,()=>{pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se))}):pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se)))}function P(O){if(C()){I();return}L();let M=u.select("domReferenceElement"),Z=xe(M);w.restTimeout.clear(),w.restTimeoutPending=!1;let W=g.current.floatingContext??f?.();if(Bt(O.relatedTarget,u.context.triggerElements))return;if(b.current&&W){u.select("open")||w.openChangeTimeout.clear();let te=d.current;w.handler=b.current({...W,tree:_,x:O.clientX,y:O.clientY,onClose(){I(),L(),E.current&&!C()&&te===u.select("domReferenceElement")&&R(O,!0)}}),Z.addEventListener("mousemove",w.handler),w.handler(O);return}(w.pointerType!=="touch"||!ie(u.select("floatingElement"),O.relatedTarget))&&R(O)}return a?it(re(N,"mousemove",H,{once:!0}),re(N,"mouseenter",H),re(N,"mouseleave",P)):it(re(N,"mouseenter",H),re(N,"mouseleave",P))},[L,I,g,S,u,o,b,w,l,A,C,i,a,x,d,_,E,f,k,j]),Ft.useMemo(()=>{if(!o)return;function R(N){w.pointerType=N.pointerType}return{onPointerDown:R,onPointerEnter:R,onMouseMove(N){let{nativeEvent:H}=N,P=N.currentTarget,O=u.select("domReferenceElement"),M=u.select("open"),Z=A(O,P,N.target);if(i&&!Rt(w.pointerType))return;if(M&&Z&&w.handleCloseOptions?.blockPointerEvents){let te=u.select("floatingElement");if(te){let se=w.handleCloseOptions?.getScope?.()??P.ownerDocument.body;Wn(w,{scopeElement:se,referenceElement:P,floatingElement:te})}}let W=Vr(x.current);if(M&&!Z||W===0||!Z&&w.restTimeoutPending&&N.movementX**2+N.movementY**2<2)return;w.restTimeout.clear();function oe(){if(w.restTimeoutPending=!1,C())return;let te=u.select("open");!w.blockMouseMove&&(!te||Z)&&j()&&u.setOpen(!0,ee(U.triggerHover,H,P))}w.pointerType==="touch"?ul.flushSync(()=>{oe()}):Z&&M?oe():(w.restTimeoutPending=!0,w.restTimeout.start(W,oe))}}},[o,w,C,A,i,u,x,j])}var fl=.1,tg=fl*fl,ce=.5;function Yn(e,t,o,n,r,i){return n>=t!=i>=t&&e<=(r-o)*(t-n)/(i-n)+o}function Un(e,t,o,n,r,i,s,a,d,c){let l=!1;return Yn(e,t,o,n,r,i)&&(l=!l),Yn(e,t,r,i,s,a)&&(l=!l),Yn(e,t,s,a,d,c)&&(l=!l),Yn(e,t,d,c,o,n)&&(l=!l),l}function og(e,t,o){return e>=o.x&&e<=o.x+o.width&&t>=o.y&&t<=o.y+o.height}function Gn(e,t,o,n,r,i){let s=Math.min(o,r),a=Math.max(o,r),d=Math.min(n,i),c=Math.max(n,i);return e>=s&&e<=a&&t>=d&&t<=c}function wi(e={}){let{blockPointerEvents:t=!1}=e,o=new Ye,n=({x:r,y:i,placement:s,elements:a,onClose:d,nodeId:c,tree:l})=>{let f=s?.split("-")[0],p=!1,m=null,u=null,g=typeof performance<"u"?performance.now():0;function v(w,y){let b=performance.now(),S=b-g;if(m===null||u===null||S===0)return m=w,u=y,g=b,!1;let x=w-m,E=y-u,T=x*x+E*E,k=S*S*tg;return m=w,u=y,g=b,T0)}function L(){A()||_()}if(A())return;let I=b.getBoundingClientRect(),R=S.getBoundingClientRect(),N=r>R.right-R.width/2,H=i>R.bottom-R.height/2,P=R.width>I.width,O=R.height>I.height,M=(P?I:R).left,Z=(P?I:R).right,W=(O?I:R).top,oe=(O?I:R).bottom;if(f==="top"&&i>=I.bottom-1||f==="bottom"&&i<=I.top+1||f==="left"&&r>=I.right-1||f==="right"&&r<=I.left+1){L();return}let te=!1;switch(f){case"top":te=Gn(x,E,M,I.top+1,Z,R.bottom-1);break;case"bottom":te=Gn(x,E,M,R.top+1,Z,I.bottom-1);break;case"left":te=Gn(x,E,R.right-1,oe,I.left+1,W);break;case"right":te=Gn(x,E,I.right-1,oe,R.left+1,W);break;default:}if(te)return;if(p&&!og(x,E,I)){L();return}if(!k&&v(x,E)){L();return}let se=!1;switch(f){case"top":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i+ce+1,me=N||P?R.bottom-ce:R.top,le=N?P?R.bottom-ce:R.top:R.bottom-ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"bottom":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i-ce,me=N||P?R.top+ce:R.bottom,le=N?P?R.top+ce:R.bottom:R.top+ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"left":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r+ce+1,me=H||O?R.right-ce:R.left,le=H?O?R.right-ce:R.left:R.right-ce;se=Un(x,E,me,R.top,le,R.bottom,ne,K,ne,J);break}case"right":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r-ce,me=H||O?R.left+ce:R.right,le=H?O?R.left+ce:R.right:R.left+ce;se=Un(x,E,ne,K,ne,J,me,R.top,le,R.bottom);break}default:}se?p||o.start(40,L):L()}};return n.__options={...e,blockPointerEvents:t},n}var vi=(function(e){return e.open="data-open",e.closed="data-closed",e[e.startingStyle=Yt.startingStyle]="startingStyle",e[e.endingStyle=Yt.endingStyle]="endingStyle",e.anchorHidden="data-anchor-hidden",e.side="data-side",e.align="data-align",e})({}),qo=(function(e){return e.popupOpen="data-popup-open",e.pressed="data-pressed",e})({}),ng={[qo.popupOpen]:""},B_={[qo.popupOpen]:"",[qo.pressed]:""},rg={[vi.open]:""},ig={[vi.closed]:""},sg={[vi.anchorHidden]:""},pl={open(e){return e?ng:null}};var Ro={open(e){return e?rg:ig},anchorHidden(e){return e?sg:null}};function ml(e){return ao(19)?e:e?"true":void 0}var Ge=h(z(),1);var ag=e=>({name:"arrow",options:e,async fn(t){let{x:o,y:n,placement:r,rects:i,platform:s,elements:a,middlewareData:d}=t,{element:c,padding:l=0,offsetParent:f="real"}=at(e,t)||{};if(c==null)return{};let p=In(l),m={x:o,y:n},u=Go(r),g=Uo(u),v=await s.getDimensions(c),_=u==="y",w=_?"top":"left",y=_?"bottom":"right",b=_?"clientHeight":"clientWidth",S=i.reference[g]+i.reference[u]-m[u]-i.floating[g],x=m[u]-i.reference[u],E=f==="real"?await s.getOffsetParent?.(c):a.floating,T=a.floating[b]||i.floating[g];(!T||!await s.isElement?.(E))&&(T=a.floating[b]||i.floating[g]);let k=S/2-x/2,C=T/2-v[g]/2-1,j=Math.min(p[w],C),A=Math.min(p[y],C),L=j,I=T-v[g]-A,R=T/2-v[g]/2+k,N=Yo(L,R,I),H=!d.arrow&&ct(r)!=null&&R!==N&&i.reference[g]/2-(R({...ag(e),options:[e,t]});var cg=ni().fn,bl={name:"hide",async fn(e){let{width:t,height:o,x:n,y:r}=e.rects.reference,i=t===0&&o===0&&n===0&&r===0;return{data:{referenceHidden:(await cg(e)).data?.referenceHidden||i}}}};var Zo={sideX:"left",sideY:"top"},hl={name:"adaptiveOrigin",async fn(e){let{x:t,y:o,rects:{floating:n},elements:{floating:r},platform:i,strategy:s,placement:a}=e,d=ge(r),c=d.getComputedStyle(r);if(!(c.transitionDuration!=="0s"&&c.transitionDuration!==""))return{x:t,y:o,data:Zo};let f=await i.getOffsetParent?.(r),p={width:0,height:0};if(s==="fixed"&&d?.visualViewport)p={width:d.visualViewport.width,height:d.visualViewport.height};else if(f===d){let w=xe(r);p={width:w.documentElement.clientWidth,height:w.documentElement.clientHeight}}else await i.isElement?.(f)&&(p=await i.getDimensions(f));let m=Ee(a),u=t,g=o;m==="left"&&(u=p.width-(t+n.width)),m==="top"&&(g=p.height-(o+n.height));let v=m==="left"?"right":Zo.sideX,_=m==="top"?"bottom":Zo.sideY;return{x:u,y:g,data:{sideX:v,sideY:_}}}};function _l(e,t,o){let n=e==="inline-start"||e==="inline-end";return{top:"top",right:n?o?"inline-start":"inline-end":"right",bottom:"bottom",left:n?o?"inline-end":"inline-start":"left"}[t]}function wl(e,t,o){let{rects:n,placement:r}=e;return{side:_l(t,Ee(r),o),align:ct(r)||"center",anchor:{width:n.reference.width,height:n.reference.height},positioner:{width:n.floating.width,height:n.floating.height}}}function yl(e){let{anchor:t,positionMethod:o="absolute",side:n="bottom",sideOffset:r=0,align:i="center",alignOffset:s=0,collisionBoundary:a,collisionPadding:d=5,sticky:c=!1,arrowPadding:l=5,disableAnchorTracking:f=!1,inline:p,keepMounted:m=!1,floatingRootContext:u,mounted:g,collisionAvoidance:v,shiftCrossAxis:_=!1,nodeId:w,adaptiveOrigin:y,lazyFlip:b=!1,externalTree:S}=e,[x,E]=Ge.useState(null);!g&&x!==null&&E(null);let T=v.side||"flip",k=v.align||"flip",C=v.fallbackAxisSide||"end",j=typeof t=="function"?t:void 0,A=Y(j),L=j?A:t,I=ze(t),R=ze(g),H=so()==="rtl",P=x||{top:"top",right:"right",bottom:"bottom",left:"left","inline-end":H?"left":"right","inline-start":H?"right":"left"}[n],O=i==="center"?P:`${P}-${i}`,M=d,Z=1,W=n==="bottom"?Z:0,oe=n==="top"?Z:0,te=n==="right"?Z:0,se=n==="left"?Z:0;typeof M=="number"?M={top:M+W,right:M+se,bottom:M+oe,left:M+te}:M&&(M={top:(M.top||0)+W,right:(M.right||0)+se,bottom:(M.bottom||0)+oe,left:(M.left||0)+te});let G={boundary:a==="clipping-ancestors"?"clippingAncestors":a,padding:M},K=Ge.useRef(null),J=ze(r),ne=ze(s),me=typeof r!="function"?r:0,le=typeof s!="function"?s:0,X=[];p&&X.push(p),X.push(Jr(ae=>{let Ie=wl(ae,n,H),ut=typeof J.current=="function"?J.current(Ie):J.current,qe=typeof ne.current=="function"?ne.current(Ie):ne.current;return{mainAxis:ut,crossAxis:qe,alignmentAxis:qe}},[me,le,H,n]));let pe=k==="none"&&T!=="shift",ue=!pe&&(c||_||T==="shift"),vt=T==="none"?null:ti({...G,padding:{top:M.top+Z,right:M.right+Z,bottom:M.bottom+Z,left:M.left+Z},mainAxis:!_&&T==="flip",crossAxis:k==="flip"?"alignment":!1,fallbackAxisSideDirection:C}),Te=pe?null:$r(ae=>{let Ie=xe(ae.elements.floating).documentElement;return{...G,rootBoundary:_?{x:0,y:0,width:Ie.clientWidth,height:Ie.clientHeight}:void 0,mainAxis:k!=="none",crossAxis:ue,limiter:c||_?void 0:ei(ut=>{if(!K.current)return{};let{width:qe,height:yt}=K.current.getBoundingClientRect(),et=De(Ee(ut.placement)),Vt=et==="y"?qe:yt,io=et==="y"?M.left+M.right:M.top+M.bottom;return{offset:Vt/2+io/2}})}},[G,c,_,M,k]);T==="shift"||k==="shift"||i==="center"?X.push(Te,vt):X.push(vt,Te),X.push(oi({...G,apply({elements:{floating:ae},availableWidth:Ie,availableHeight:ut,rects:qe}){if(!R.current)return;let yt=ae.style;yt.setProperty("--available-width",`${Ie}px`),yt.setProperty("--available-height",`${ut}px`);let et=ge(ae).devicePixelRatio||1,{x:Vt,y:io,width:bn,height:br}=qe.reference,hr=(Math.round((Vt+bn)*et)-Math.round(Vt*et))/et,wr=(Math.round((io+br)*et)-Math.round(io*et))/et;yt.setProperty("--anchor-width",`${hr}px`),yt.setProperty("--anchor-height",`${wr}px`)}}),gl(ae=>({element:K.current||xe(ae.elements.floating).createElement("div"),padding:l,offsetParent:"floating"}),[l]),{name:"transformOrigin",fn(ae){let{elements:Ie,middlewareData:ut,placement:qe,rects:yt,y:et}=ae,Vt=Ee(qe),io=De(Vt),bn=K.current,br=ut.arrow?.x||0,hr=ut.arrow?.y||0,wr=bn?.clientWidth||0,ff=bn?.clientHeight||0,vr=br+wr/2,Us=hr+ff/2,pf=Math.abs(ut.shift?.y||0),mf=yt.reference.height/2,Io=typeof r=="function"?r(wl(ae,n,H)):r,gf=pf>Io,bf={top:`${vr}px calc(100% + ${Io}px)`,bottom:`${vr}px ${-Io}px`,left:`calc(100% + ${Io}px) ${Us}px`,right:`${-Io}px ${Us}px`}[Vt],hf=`${vr}px ${yt.reference.y+mf-et}px`;return Ie.floating.style.setProperty("--transform-origin",ue&&io==="y"&&gf?hf:bf),{}}},bl,y),D(()=>{!g&&u&&u.update({referenceElement:null,floatingElement:null,domReferenceElement:null,positionReference:null})},[g,u]);let Ve=Ge.useMemo(()=>({elementResize:!f&&typeof ResizeObserver<"u",layoutShift:!f&&typeof IntersectionObserver<"u"}),[f]),{refs:Ke,elements:He,x:no,y:dn,middlewareData:_e,update:ro,placement:B,context:F,isPositioned:he,floatingStyles:ke}=ui({rootContext:u,open:m?g:void 0,placement:O,middleware:X,strategy:o,whileElementsMounted:m?void 0:(...ae)=>Xo(...ae,Ve),nodeId:w,externalTree:S}),{sideX:kt,sideY:Lo}=_e.adaptiveOrigin||Zo,_t=he?o:"fixed",We=Ge.useMemo(()=>{let ae=y?{position:_t,[kt]:no,[Lo]:dn}:{position:_t,...ke};return he||(ae.opacity=0),ae},[y,_t,kt,no,Lo,dn,ke,he]),Pt=Ge.useRef(null);D(()=>{if(!g)return;let ae=I.current,Ie=typeof ae=="function"?ae():ae,qe=(vl(Ie)?Ie.current:Ie)||null||null;qe!==Pt.current&&(Ke.setPositionReference(qe),Pt.current=qe)},[g,Ke,L,I]),Ge.useEffect(()=>{if(!g)return;let ae=I.current;typeof ae!="function"&&vl(ae)&&ae.current!==Pt.current&&(Ke.setPositionReference(ae.current),Pt.current=ae.current)},[g,Ke,L,I]),Ge.useEffect(()=>{if(m&&g&&He.reference&&He.floating)return Xo(He.reference,He.floating,ro,Ve)},[m,g,He,ro,Ve]);let Ct=Ee(B),un=_l(n,Ct,H),fn=ct(B)||"center",pn=!!_e.hide?.referenceHidden;D(()=>{b&&g&&he&&E(Ct)},[b,g,he,Ct]);let mn=Ge.useMemo(()=>({position:"absolute",top:_e.arrow?.y,left:_e.arrow?.x}),[_e.arrow]),gn=_e.arrow?.centerOffset!==0;return Ge.useMemo(()=>({positionerStyles:We,arrowStyles:mn,arrowRef:K,arrowUncentered:gn,side:un,align:fn,physicalSide:Ct,anchorHidden:pn,refs:Ke,context:F,isPositioned:he,update:ro}),[We,mn,K,gn,un,fn,Ct,pn,Ke,F,he,ro])}function vl(e){return e!=null&&"current"in e}function Xn(e){return e==="starting"?Za:be}function xl(e,t,{styles:o,transitionStatus:n,props:r,refs:i,hidden:s,inert:a=!1}){let d={...o};return a&&(d.pointerEvents="none"),Ce("div",e,{state:t,ref:i,props:[{role:"presentation",hidden:s,style:d},Xn(n),r],stateAttributesMapping:Ro})}var Rl=h(z(),1);var _i=Rl.forwardRef(function(t,o){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:s=!1,nativeButton:a=!0,style:d,...c}=t,{getButtonProps:l,buttonRef:f}=Ea({disabled:i,focusableWhenDisabled:s,native:a});return Ce("button",t,{state:{disabled:i},ref:[o,f],props:[c,l]})});var Le=h(z(),1),Cl=h(Mt(),1);var Sl=h(z(),1);function El(e){let[t,o]=Sl.useState({current:e,previous:null});return e!==t.current&&o({current:e,previous:t.current}),t.previous}var So=h(z(),1);function yi(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n;return(zt(o)!==i||zt(n)!==s)&&(o=i,n=s),{width:o,height:n}}function kl(e){let{popupElement:t,positionerElement:o,content:n,mounted:r,onMeasureLayout:i,onMeasureLayoutComplete:s,side:a,direction:d}=e,c=mo(t,!0,!1),l=lo(),f=So.useRef(null),p=So.useRef(!0),m=So.useRef(Nt),u=Y(i),g=Y(s),v=So.useMemo(()=>{let _=a==="top",w=a==="left";return d==="rtl"?(_=_||a==="inline-end",w=w||a==="inline-end"):(_=_||a==="inline-start",w=w||a==="inline-start"),_?{position:"absolute",[a==="top"?"bottom":"top"]:"0",[w?"right":"left"]:"0"}:be},[a,d]);D(()=>{if(!r){m.current=Nt,p.current=!0,f.current=null;return}if(!t||!o)return;m.current=Tl(t,v),xi(t,"auto");let _=qn(t,"position","static"),w=qn(t,"transform","none"),y=qn(t,"scale","1"),b=Tl(o,{"--available-width":"max-content","--available-height":"max-content"});function S(){_(),w(),b()}function x(){S(),y()}if(u?.(),p.current||f.current===null){Kn(o,"max-content");let C=yi(t);return f.current=C,Kn(o,C),x(),g?.(null,C),p.current=!1,()=>{m.current(),m.current=Nt}}Kn(o,"max-content");let E=f.current,T=yi(t);f.current=T,xi(t,E),x(),g?.(E,T),Kn(o,T);let k=new AbortController;return l.request(()=>{xi(t,T),c(()=>{t.style.setProperty("--popup-width","auto"),t.style.setProperty("--popup-height","auto")},k.signal)}),()=>{k.abort(),l.cancel(),m.current(),m.current=Nt}},[n,t,o,c,l,r,u,g,v])}function qn(e,t,o){let n=e.style.getPropertyValue(t);return e.style.setProperty(t,o),()=>{e.style.setProperty(t,n)}}function Tl(e,t){let o=[];for(let[n,r]of Object.entries(t))o.push(qn(e,n,r));return o.length?()=>{o.forEach(n=>n())}:Nt}function xi(e,t){let o=t==="auto"?"auto":`${t.width}px`,n=t==="auto"?"auto":`${t.height}px`;e.style.setProperty("--popup-width",o),e.style.setProperty("--popup-height",n)}function Kn(e,t){let o=t==="max-content"?"max-content":`${t.width}px`,n=t==="max-content"?"max-content":`${t.height}px`;e.style.setProperty("--positioner-width",o),e.style.setProperty("--positioner-height",n)}var Eo=h(Q(),1);function Al(e){let{store:t,side:o,cssVars:n,children:r}=e,i=so(),s=t.useState("activeTriggerElement"),a=t.useState("activeTriggerId"),d=t.useState("open"),c=t.useState("payload"),l=t.useState("mounted"),f=t.useState("popupElement"),p=t.useState("positionerElement"),m=El(d?s:null),u=ug(a,c),g=Le.useRef(null),[v,_]=Le.useState(null),[w,y]=Le.useState(null),b=Le.useRef(null),S=Le.useRef(null),x=mo(b,!0,!1),E=lo(),[T,k]=Le.useState(null),[C,j]=Le.useState(!1);D(()=>(t.set("hasViewport",!0),()=>{t.set("hasViewport",!1)}),[t]);let A=Y(()=>{b.current?.style.setProperty("animation","none"),b.current?.style.setProperty("transition","none"),S.current?.style.setProperty("display","none")}),L=Y(P=>{b.current?.style.removeProperty("animation"),b.current?.style.removeProperty("transition"),S.current?.style.removeProperty("display"),P&&k(P)}),I=Le.useRef(null);D(()=>{(!d||!l)&&(I.current=null)},[d,l]),D(()=>{if(s&&m&&s!==m&&I.current!==s&&g.current){_(g.current),j(!0);let P=dg(m,s);y(P),E.request(()=>{Cl.flushSync(()=>{j(!1)}),x(()=>{_(null),k(null),g.current=null})}),I.current=s}},[s,m,v,x,E]),D(()=>{let P=b.current;if(!P)return;let O=xe(P).createElement("div");for(let M of Array.from(P.childNodes))O.appendChild(M.cloneNode(!0));g.current=O});let R=v!=null,N;R?N=(0,Eo.jsxs)(Le.Fragment,{children:[(0,Eo.jsx)("div",{"data-previous":!0,inert:ml(!0),ref:S,style:{...T?{[n.popupWidth]:`${T.width}px`,[n.popupHeight]:`${T.height}px`}:null,position:"absolute"},"data-ending-style":C?void 0:""},"previous"),(0,Eo.jsx)("div",{"data-current":!0,ref:b,"data-starting-style":C?"":void 0,children:r},u)]}):N=(0,Eo.jsx)("div",{"data-current":!0,ref:b,children:r},u),D(()=>{let P=S.current;!P||!v||P.replaceChildren(...Array.from(v.childNodes))},[v]),kl({popupElement:f,positionerElement:p,mounted:l,content:c,onMeasureLayout:A,onMeasureLayoutComplete:L,side:o,direction:i});let H={activationDirection:lg(w),transitioning:R};return{children:N,state:H}}function lg(e){if(e)return`${Pl(e.horizontal,5,"right","left")} ${Pl(e.vertical,5,"down","up")}`}function Pl(e,t,o,n){return e>t?o:e<-t?n:""}function dg(e,t){let o=e.getBoundingClientRect(),n=t.getBoundingClientRect(),r={x:o.left+o.width/2,y:o.top+o.height/2},i={x:n.left+n.width/2,y:n.top+n.height/2};return{horizontal:i.x-r.x,vertical:i.y-r.y}}function ug(e,t){let[o,n]=Le.useState(0),r=Le.useRef(e),i=Le.useRef(t),s=Le.useRef(!1);return D(()=>{let a=r.current,d=i.current,c=e!==a,l=t!==d;c?(n(f=>f+1),s.current=!l):s.current&&l&&(n(f=>f+1),s.current=!1),r.current=e,i.current=t},[e,t]),`${e??"current"}-${o}`}var Zn=h(z(),1),Ol=h(Mt(),1);var Nl=h(Q(),1),Ll=Zn.forwardRef(function(t,o){let{children:n,container:r,className:i,render:s,style:a,...d}=t,{portalNode:c,portalSubtree:l}=Ur({container:r,ref:o,componentProps:t,elementProps:d});return!l&&!c?null:(0,Nl.jsxs)(Zn.Fragment,{children:[l,c&&Ol.createPortal(n,c)]})});var Qe={};At(Qe,{Arrow:()=>ql,Handle:()=>Qo,Popup:()=>Xl,Portal:()=>Wl,Positioner:()=>Ul,Provider:()=>Zl,Root:()=>Ml,Trigger:()=>jl,Viewport:()=>$l,createHandle:()=>ed});var gt=h(z(),1);var Qn=h(z(),1),Ri=Qn.createContext(void 0);function Ze(e){let t=Qn.useContext(Ri);if(t===void 0&&!e)throw new Error(Pe(72));return t}var Il=h(z(),1);var fg={...ll,disabled:q(e=>e.disabled),instantType:q(e=>e.instantType),isInstantPhase:q(e=>e.isInstantPhase),trackCursorAxis:q(e=>e.trackCursorAxis),disableHoverablePopup:q(e=>e.disableHoverablePopup),lastOpenChangeReason:q(e=>e.openChangeReason),closeOnClick:q(e=>e.closeOnClick),closeDelay:q(e=>e.closeDelay),hasViewport:q(e=>e.hasViewport)},To=class e extends vo{constructor(t,o,n=!1){let r=new jt,i={...pg(),...t};i.floatingRootContext=al(r,o,n),super(i,{popupRef:Il.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:r},fg)}setOpen=(t,o)=>{Jc(this,t,o,{extraState:{openChangeReason:o.reason}})};cancelPendingOpen(t){this.state.floatingRootContext.dispatchOpenChange(!1,ee(U.triggerPress,t))}static useStore(t,o){return Qc(t,(r,i)=>new e(o,r,i)).store}};function pg(){return{...sl(),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:"none",disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,hasViewport:!1}}var Jn=h(Q(),1),Ml=ci(function(t){let{disabled:o=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:s="none",actionsRef:a,onOpenChange:d,onOpenChangeComplete:c,handle:l,triggerId:f,defaultTriggerId:p=null,children:m}=t,u=To.useStore(l?.store,{open:n,openProp:r,activeTriggerId:p,triggerIdProp:f});$c(u,r,n,p),u.useControlledProp("openProp",r),u.useControlledProp("triggerIdProp",f),u.useContextCallback("onOpenChange",d),u.useContextCallback("onOpenChangeComplete",c);let g=u.useState("open"),v=!o&&g,_=u.useState("activeTriggerId"),w=u.useState("mounted"),y=u.useState("payload");u.useSyncedValues({trackCursorAxis:s,disableHoverablePopup:i}),u.useSyncedValue("disabled",o),tl(u,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:b,transitionStatus:S}=ol(v,u),x=u.useState("isInstantPhase"),E=u.useState("instantType"),T=u.useState("lastOpenChangeReason"),k=gt.useRef(null);D(()=>{g&&o&&u.setOpen(!1,ee(U.disabled))},[g,o,u]),D(()=>{S==="ending"&&T===U.none||S!=="ending"&&x?(E!=="delay"&&(k.current=E),u.set("instantType","delay")):k.current!==null&&(u.set("instantType",k.current),k.current=null)},[S,x,T,E,u]),D(()=>{v&&_==null&&u.set("payload",void 0)},[u,_,v]);let C=gt.useCallback(()=>{u.setOpen(!1,ee(U.imperativeAction))},[u]);gt.useImperativeHandle(a,()=>({unmount:b,close:C}),[b,C]);let j=v||w||!o&&s!=="none";return(0,Jn.jsxs)(Ri.Provider,{value:u,children:[j&&(0,Jn.jsx)(mg,{store:u,disabled:o,trackCursorAxis:s}),typeof m=="function"?m({payload:y}):m]})});function mg({store:e,disabled:t,trackCursorAxis:o}){let n=e.useState("floatingRootContext"),r=Xr(n,{enabled:!t,referencePress:()=>e.select("closeOnClick")}),i=Gr(n,{enabled:!t&&o!=="none",axis:o==="none"?void 0:o}),s=gt.useMemo(()=>ye(i.reference,r.reference),[i.reference,r.reference]),a=gt.useMemo(()=>ye(i.trigger,r.trigger),[i.trigger,r.trigger]),d=gt.useMemo(()=>ye(Zc,i.floating,r.floating),[i.floating,r.floating]);return nl(e,{activeTriggerProps:s,inactiveTriggerProps:a,popupProps:d}),null}var er=h(z(),1);var $n=h(z(),1),Si=$n.createContext(void 0);function Bl(){return $n.useContext(Si)}var Hl=(function(e){return e[e.popupOpen=qo.popupOpen]="popupOpen",e.triggerDisabled="data-trigger-disabled",e})({});var Dl="data-base-ui-tooltip-trigger";function zl(e){if("composedPath"in e){let o=e.composedPath();for(let n=0;ng.select("transitionStatus")==="ending",shouldOpen(){return!O.current}}),G=pi(y,{enabled:!R}).reference,K=X=>{let pe=O.current,ue=zl(X),vt=te(ue),Te=b.current,Ve=Te&&ue&&ie(Te,ue);if(vt&&g.select("open")&&g.select("lastOpenChangeReason")===U.triggerHover){g.setOpen(!1,ee(U.triggerHover,X));return}if(pe&&!vt&&Ve&&!N.current&&!g.select("open")&&Te&&Rt(Z.current)){let Ke=()=>{!O.current&&!N.current&&!g.select("open")&&g.setOpen(!0,ee(U.triggerHover,X,Te))},He=W();He===0?(M.clear(),Ke()):M.start(He,Ke)}},J=g.useState("triggerProps",T);return Ce("button",t,{state:{open:w},ref:[o,E,b],props:[se,G,T||H!=="none"?J:void 0,{onMouseOver(X){K(X.nativeEvent)},onFocus(X){oe(zl(X.nativeEvent))&&X.preventBaseUIHandler()},onMouseLeave(){O.current=!1,M.clear(),Z.current=void 0},onPointerEnter(X){Z.current=X.pointerType},onPointerDown(X){Z.current=X.pointerType,g.set("closeOnClick",l),l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},onClick(X){l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},id:v,[Hl.triggerDisabled]:R?"":void 0,[Dl]:R?void 0:""},m],stateAttributesMapping:pl})});var Vl=h(z(),1);var tr=h(z(),1),Ei=tr.createContext(void 0);function Fl(){let e=tr.useContext(Ei);if(e===void 0)throw new Error(Pe(70));return e}var Ti=h(Q(),1),Wl=Vl.forwardRef(function(t,o){let{keepMounted:n=!1,...r}=t;return Ze().useState("mounted")||n?(0,Ti.jsx)(Ei.Provider,{value:n,children:(0,Ti.jsx)(Ll,{ref:o,...r})}):null});var nr=h(z(),1);var or=h(z(),1),ki=or.createContext(void 0);function ko(){let e=or.useContext(ki);if(e===void 0)throw new Error(Pe(71));return e}var Yl=h(Q(),1),Ul=nr.forwardRef(function(t,o){let{render:n,className:r,anchor:i,positionMethod:s="absolute",side:a="top",align:d="center",sideOffset:c=0,alignOffset:l=0,collisionBoundary:f="clipping-ancestors",collisionPadding:p=5,arrowPadding:m=5,sticky:u=!1,disableAnchorTracking:g=!1,collisionAvoidance:v=Qa,style:_,...w}=t,y=Ze(),b=Fl(),S=y.useState("open"),x=y.useState("mounted"),E=y.useState("trackCursorAxis"),T=y.useState("disableHoverablePopup"),k=y.useState("floatingRootContext"),C=y.useState("instantType"),j=y.useState("transitionStatus"),A=y.useState("hasViewport"),L=yl({anchor:i,positionMethod:s,floatingRootContext:k,mounted:x,side:a,sideOffset:c,align:d,alignOffset:l,collisionBoundary:f,collisionPadding:p,sticky:u,arrowPadding:m,disableAnchorTracking:g,keepMounted:b,collisionAvoidance:v,adaptiveOrigin:A?hl:void 0}),I=nr.useMemo(()=>({open:S,side:L.side,align:L.align,anchorHidden:L.anchorHidden,instant:E!=="none"?"tracking-cursor":C}),[S,L.side,L.align,L.anchorHidden,E,C]),R=xl(t,I,{styles:L.positionerStyles,transitionStatus:j,props:w,refs:[o,y.useStateSetter("positionerElement")],hidden:!x,inert:!S||E==="both"||T});return(0,Yl.jsx)(ki.Provider,{value:L,children:R})});var Gl=h(z(),1);var bg={...Ro,...wa},Xl=Gl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{side:d,align:c}=ko(),l=a.useState("open"),f=a.useState("instantType"),p=a.useState("transitionStatus"),m=a.useState("popupProps"),u=a.useState("floatingRootContext"),g=a.useState("disabled"),v=a.useState("closeDelay");Pn({open:l,ref:a.context.popupRef,onComplete(){l&&a.context.onOpenChangeComplete?.(!0)}}),bi(u,{enabled:!g,closeDelay:v});let _=a.useStateSetter("popupElement");return Ce("div",t,{state:{open:l,side:d,align:c,instant:f,transitionStatus:p},ref:[o,a.context.popupRef,_],props:[m,Xn(p),s],stateAttributesMapping:bg})});var Kl=h(z(),1);var ql=Kl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{arrowRef:d,side:c,align:l,arrowUncentered:f,arrowStyles:p}=ko(),m=a.useState("open"),u=a.useState("instantType");return Ce("div",t,{state:{open:m,side:c,align:l,uncentered:f,instant:u},ref:[o,d],props:[{style:p,"aria-hidden":!0},s],stateAttributesMapping:Ro})});var Pi=h(z(),1);var Ci=h(Q(),1),Zl=function(t){let{delay:o,closeDelay:n,timeout:r=400}=t,i=Pi.useMemo(()=>({delay:o,closeDelay:n}),[o,n]),s=Pi.useMemo(()=>({open:o,close:n}),[o,n]);return(0,Ci.jsx)(Si.Provider,{value:i,children:(0,Ci.jsx)(Wr,{delay:s,timeoutMs:r,children:t.children})})};var Jl=h(z(),1);var Ql=(function(e){return e.popupWidth="--popup-width",e.popupHeight="--popup-height",e})({});var hg={activationDirection:e=>e?{"data-activation-direction":e}:null},$l=Jl.forwardRef(function(t,o){let{render:n,className:r,style:i,children:s,...a}=t,d=Ze(),c=ko(),l=d.useState("instantType"),{children:f,state:p}=Al({store:d,side:c.side,cssVars:Ql,children:s}),m={activationDirection:p.activationDirection,transitioning:p.transitioning,instant:l};return Ce("div",t,{state:m,ref:o,props:[a,{children:f}],stateAttributesMapping:hg})});var Qo=class{constructor(){this.store=new To}open(t){let o=t?this.store.context.triggerElements.getById(t):void 0;if(t&&!o)throw new Error(Pe(81,t));this.store.setOpen(!0,ee(U.imperativeAction,void 0,o))}close(){this.store.setOpen(!1,ee(U.imperativeAction,void 0,void 0))}get isOpen(){return this.store.select("open")}};function ed(){return new Qo}function bt(e){return Ce(e.defaultTagName??"div",e,e)}var nd=h(de(),1),Ai="data-wp-hash";function Oi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vg(document)),e.__wpStyleRuntime}function wg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ai}]`))if(o.getAttribute(Ai)===t)return!0;return!1}function rd(e,t,o){if(!e.head)return;let n=Oi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ai,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vg(e){let t=Oi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)rd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function id(e,t){let o=Oi();o.styles.set(e,t);for(let n of o.documents.keys())rd(n,e,t)}typeof process>"u",id("a495f9d138",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._83ed8a8da5dd50ea__text{margin:0}._14437cfb77831647__heading-2xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-p-line-height:var(--wpds-typography-line-height-2xl,40px);font-size:var(--wpds-typography-font-size-2xl,32px);line-height:var(--wpds-typography-line-height-2xl,40px)}._14437cfb77831647__heading-2xl,._3c78b7fa9b4072dd__heading-xl{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600)}._3c78b7fa9b4072dd__heading-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-md,24px)}.aa58f227716bcde2__heading-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-lg,15px)}.aa58f227716bcde2__heading-lg,.fc4da56d8dfe52c4__heading-md{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-sm,20px)}.fc4da56d8dfe52c4__heading-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px)}.a9b78c7c82e8dff7__heading-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-xs,11px);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-xs,16px);text-transform:uppercase}._305ff559e52180d5__body-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-xl,32px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-xl,32px)}._305ff559e52180d5__body-xl,.ca1aa3fc2029e958__body-lg{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}.ca1aa3fc2029e958__body-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-lg,15px);line-height:var(--wpds-typography-line-height-md,24px)}._131101940be12424__body-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px);line-height:var(--wpds-typography-line-height-sm,20px)}._0e8d87a42c1f75fa__body-sm,._131101940be12424__body-md{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}._0e8d87a42c1f75fa__body-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-size:var(--wpds-typography-font-size-sm,12px);line-height:var(--wpds-typography-line-height-xs,16px)}}}');var td={text:"_83ed8a8da5dd50ea__text","heading-2xl":"_14437cfb77831647__heading-2xl","heading-xl":"_3c78b7fa9b4072dd__heading-xl","heading-lg":"aa58f227716bcde2__heading-lg","heading-md":"fc4da56d8dfe52c4__heading-md","heading-sm":"a9b78c7c82e8dff7__heading-sm","body-xl":"_305ff559e52180d5__body-xl","body-lg":"ca1aa3fc2029e958__body-lg","body-md":"_131101940be12424__body-md","body-sm":"_0e8d87a42c1f75fa__body-sm"};typeof process>"u",id("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var od={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},Je=(0,nd.forwardRef)(function({variant:t="body-md",render:o,className:n,...r},i){return bt({render:o,defaultTagName:"span",ref:i,props:ye(r,{className:$(td.text,od.heading,od.p,td[t],n)})})});var ld=h(Q(),1),Ni="data-wp-hash";function Li(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&yg(document)),e.__wpStyleRuntime}function _g(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ni}]`))if(o.getAttribute(Ni)===t)return!0;return!1}function cd(e,t,o){if(!e.head)return;let n=Li(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(_g(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ni,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function yg(e){let t=Li();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function xg(e,t){let o=Li();o.styles.set(e,t);for(let n of o.documents.keys())cd(n,e,t)}typeof process>"u",xg("9db2873e7f","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-background-surface-error,#f6e6e3);color:var(--wpds-color-foreground-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-background-surface-warning,#fde6be);color:var(--wpds-color-foreground-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-background-surface-caution,#fee995);color:var(--wpds-color-foreground-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-background-surface-success,#c6f7cd);color:var(--wpds-color-foreground-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-background-surface-info,#deebfa);color:var(--wpds-color-foreground-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);color:var(--wpds-color-foreground-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral,#dbdbdb);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);padding-block:calc(var(--wpds-dimension-padding-xs, 4px) - var(--wpds-border-width-xs, 1px));padding-inline:calc(var(--wpds-dimension-padding-sm, 8px) - var(--wpds-border-width-xs, 1px))}}}");var sd={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},Ii=(0,ad.forwardRef)(function({intent:t="none",className:o,...n},r){return(0,ld.jsx)(Je,{ref:r,className:$(sd.badge,sd[`is-${t}-intent`],o),...n,variant:"body-sm"})});var rr=h(de(),1),dd=h(Ot(),1),fd=h(Q(),1);import{speak as Rg}from"@wordpress/a11y";var Mi="data-wp-hash";function Bi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Eg(document)),e.__wpStyleRuntime}function Sg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Mi}]`))if(o.getAttribute(Mi)===t)return!0;return!1}function ud(e,t,o){if(!e.head)return;let n=Bi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Sg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Mi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Eg(e){let t=Bi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)ud(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function ir(e,t){let o=Bi();o.styles.set(e,t);for(let n of o.documents.keys())ud(n,e,t)}typeof process>"u",ir("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Jo={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"};typeof process>"u",ir("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Tg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",ir("5f8e7aa0bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}");var kg={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible"};typeof process>"u",ir("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var Pg={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},pd=(0,rr.forwardRef)(function({tone:t="brand",variant:o="solid",size:n="default",className:r,focusableWhenDisabled:i=!0,disabled:s,loading:a,loadingAnnouncement:d=(0,dd.__)("Loading"),children:c,...l},f){let p=$(Pg.button,Tg["box-sizing"],kg["outset-ring--focus-except-active"],o!=="unstyled"&&Jo.button,Jo[`is-${t}`],Jo[`is-${o}`],Jo[`is-${n}`],a&&Jo["is-loading"],r);return(0,rr.useEffect)(()=>{a&&d&&Rg(d)},[a,d]),(0,fd.jsx)(_i,{ref:f,className:p,focusableWhenDisabled:i,disabled:s??a,...l,children:c})});var wd=h(de(),1);var gd=h(de(),1),bd=h($t(),1),hd=h(Q(),1),eo=(0,gd.forwardRef)(function({icon:t,size:o=24,...n},r){return(0,hd.jsx)(bd.SVG,{ref:r,...t.props,...n,width:o,height:o})});var _d=h(Q(),1),Hi="data-wp-hash";function zi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ag(document)),e.__wpStyleRuntime}function Cg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Hi}]`))if(o.getAttribute(Hi)===t)return!0;return!1}function vd(e,t,o){if(!e.head)return;let n=zi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Cg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Hi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ag(e){let t=zi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)vd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Og(e,t){let o=zi();o.styles.set(e,t);for(let n of o.documents.keys())vd(n,e,t)}typeof process>"u",Og("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Ng={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"},Di=(0,wd.forwardRef)(function({className:t,icon:o,...n},r){return(0,_d.jsx)(eo,{ref:r,icon:o,className:$(Ng.icon,t),size:24,...n})});Di.displayName="Button.Icon";var sr=Object.assign(pd,{Icon:Di});var ar=h($t(),1),ji=h(Q(),1),Fi=(0,ji.jsx)(ar.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,ji.jsx)(ar.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 12v-1.5h1.5V16h-1.5Zm0-8v5h1.5V8h-1.5Z"})});var cr=h($t(),1),Vi=h(Q(),1),Wi=(0,Vi.jsx)(cr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Vi.jsx)(cr.Path,{d:"M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"})});var lr=h($t(),1),Yi=h(Q(),1),Ui=(0,Yi.jsx)(lr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Yi.jsx)(lr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12.218 5.377a.25.25 0 0 0-.436 0l-7.29 12.96a.25.25 0 0 0 .218.373h14.58a.25.25 0 0 0 .218-.372l-7.29-12.96Zm-1.743-.735c.669-1.19 2.381-1.19 3.05 0l7.29 12.96a1.75 1.75 0 0 1-1.525 2.608H4.71a1.75 1.75 0 0 1-1.525-2.608l7.29-12.96ZM12.75 17.46h-1.5v-1.5h1.5v1.5Zm-1.5-3h1.5v-5h-1.5v5Z"})});var dr=h($t(),1),Gi=h(Q(),1),Xi=(0,Gi.jsx)(dr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Gi.jsx)(dr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4v1.5h-1.5V8h1.5Zm0 8v-5h-1.5v5h1.5Z"})});var ur=h($t(),1),Ki=h(Q(),1),qi=(0,Ki.jsx)(ur.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Ki.jsx)(ur.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm11.53-1.47-1.06-1.06L11 12.94l-1.47-1.47-1.06 1.06L11 15.06l4.53-4.53Z"})});var yd=h(de(),1);function Zi(e,t,o){return(0,yd.cloneElement)(e??t,{children:o})}var Lg=h(Rd(),1);var Ed=h(Qi(),1),{lock:h4,unlock:Td}=(0,Ed.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/ui");function Ig(){let e=Lg;if(e.ThemeProvider)return e.ThemeProvider;if(!e.privateApis)throw new Error("@wordpress/ui: @wordpress/theme must expose `ThemeProvider` or `privateApis.ThemeProvider`.");return Td(e.privateApis).ThemeProvider}var kd=Ig();var Pd=h(de(),1),Ji="data-wp-hash";function $i(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Bg(document)),e.__wpStyleRuntime}function Mg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ji}]`))if(o.getAttribute(Ji)===t)return!0;return!1}function Cd(e,t,o){if(!e.head)return;let n=$i(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Mg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ji,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Bg(e){let t=$i();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Hg(e,t){let o=$i();o.styles.set(e,t);for(let n of o.documents.keys())Cd(n,e,t)}typeof process>"u",Hg("32aba35fe1","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._19ce0419607e1896__stack{display:flex}}}");var zg={stack:"_19ce0419607e1896__stack"},Dg={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},Po=(0,Pd.forwardRef)(function({direction:t,gap:o,align:n,justify:r,wrap:i,render:s,...a},d){let c={gap:o&&Dg[o],alignItems:n,justifyContent:r,flexDirection:t,flexWrap:i};return bt({render:s,ref:d,props:ye(a,{style:c,className:zg.stack})})});var Kd=h(de(),1);var Vd=h(de(),1);var Id=h(de(),1);var ts="data-wp-hash";function os(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Fg(document)),e.__wpStyleRuntime}function jg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ts}]`))if(o.getAttribute(ts)===t)return!0;return!1}function Od(e,t,o){if(!e.head)return;let n=os(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(jg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Fg(e){let t=os();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Od(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Vg(e,t){let o=os();o.styles.set(e,t);for(let n of o.documents.keys())Od(n,e,t)}typeof process>"u",Vg("be37f31c1e","._11fc52b637ff8a7e__slot{inset:0;isolation:isolate;pointer-events:none;position:fixed;z-index:1000000003}@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._11fc52b637ff8a7e__slot>*{pointer-events:auto}}}");var Ad={slot:"_11fc52b637ff8a7e__slot"},Nd="data-wp-compat-overlay-slot";function Wg(){return typeof document>"u"?null:document}function Yg(){let e;try{e=window.top?.wp}catch{}let t=e??window.wp;return typeof t?.components=="object"&&t.components!==null}var ht=null;function es(e){return e.setAttribute("aria-hidden","false"),e}function Ug(e){let t=e.createElement("div");return t.setAttribute(Nd,""),Ad.slot&&t.classList.add(Ad.slot),e.body.appendChild(t),t}function Ld(){if(typeof window>"u"||!Yg()&&window.__wpUiCompatOverlaySlotEnabled!==!0)return;let e=Wg();if(!e||!e.body)return;if(ht&&ht.ownerDocument===e&&ht.isConnected)return es(ht);let t=e.querySelector(`[${Nd}]`);return t instanceof HTMLDivElement?(ht=es(t),ht):(ht?.isConnected&&ht.remove(),ht=es(Ug(e)),ht)}var Md=h(Q(),1),Bd=(0,Id.forwardRef)(function({container:t,...o},n){return(0,Md.jsx)(Qe.Portal,{container:t??Ld(),...o,ref:n})});var Hd=h(de(),1),jd=h(Q(),1),ns="data-wp-hash";function rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Xg(document)),e.__wpStyleRuntime}function Gg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ns}]`))if(o.getAttribute(ns)===t)return!0;return!1}function zd(e,t,o){if(!e.head)return;let n=rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Gg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ns,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Xg(e){let t=rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)zd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Dd(e,t){let o=rs();o.styles.set(e,t);for(let n of o.documents.keys())zd(n,e,t)}typeof process>"u",Dd("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Kg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",Dd("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var qg={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},Fd=(0,Hd.forwardRef)(function({align:t="center",className:o,side:n="top",sideOffset:r=4,...i},s){return(0,jd.jsx)(Qe.Positioner,{ref:s,align:t,side:n,sideOffset:r,...i,className:$(Kg["box-sizing"],qg.positioner,o)})});var $o=h(Q(),1),is="data-wp-hash";function ss(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Qg(document)),e.__wpStyleRuntime}function Zg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${is}]`))if(o.getAttribute(is)===t)return!0;return!1}function Wd(e,t,o){if(!e.head)return;let n=ss(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Zg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Qg(e){let t=ss();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Wd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Jg(e,t){let o=ss();o.styles.set(e,t);for(let n of o.documents.keys())Wd(n,e,t)}typeof process>"u",Jg("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var $g={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},eb={background:"#1e1e1e"},as=(0,Vd.forwardRef)(function({portal:t,positioner:o,children:n,className:r,...i},s){let a=(0,$o.jsx)(kd,{color:eb,children:(0,$o.jsx)(Qe.Popup,{ref:s,className:$($g.popup,r),...i,children:n})}),d=Zi(o,(0,$o.jsx)(Fd,{}),a);return Zi(t,(0,$o.jsx)(Bd,{}),d)});var Yd=h(de(),1),Ud=h(Q(),1),cs=(0,Yd.forwardRef)(function(t,o){return(0,Ud.jsx)(Qe.Trigger,{ref:o,...t})});var Gd=h(Q(),1);function ls(e){return(0,Gd.jsx)(Qe.Root,{...e})}var lt=h(Q(),1),ds="data-wp-hash";function us(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&nb(document)),e.__wpStyleRuntime}function ob(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ds}]`))if(o.getAttribute(ds)===t)return!0;return!1}function qd(e,t,o){if(!e.head)return;let n=us(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ob(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ds,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function nb(e){let t=us();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)qd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function rb(e,t){let o=us();o.styles.set(e,t);for(let n of o.documents.keys())qd(n,e,t)}typeof process>"u",rb("c5cdafb1bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer compositions{._28cfdc260e755391__icon-button{--wp-ui-button-aspect-ratio:1;--wp-ui-button-padding-inline:0px;--wp-ui-button-min-width:unset}.f1c70d719989a85a__icon{margin:-1px}}}");var Xd={"icon-button":"_28cfdc260e755391__icon-button",icon:"f1c70d719989a85a__icon"},fs=(0,Kd.forwardRef)(function({label:t,className:o,children:n,disabled:r,focusableWhenDisabled:i=!0,icon:s,size:a,shortcut:d,positioner:c,...l},f){let p=$(Xd["icon-button"],o);return(0,lt.jsxs)(ls,{children:[(0,lt.jsx)(cs,{ref:f,disabled:r&&!i,render:(0,lt.jsx)(sr,{...l,size:a,"aria-label":t,"aria-keyshortcuts":d?.ariaKeyShortcut,disabled:r,focusableWhenDisabled:i}),className:p,children:(0,lt.jsx)(eo,{icon:s,size:24,className:Xd.icon})}),(0,lt.jsxs)(as,{positioner:c,children:[t,d&&(0,lt.jsxs)(lt.Fragment,{children:[" ",(0,lt.jsx)("span",{"aria-hidden":"true",children:d.displayShortcut})]})]})]})});var Zd=h(de(),1),Qd=h(Ot(),1),Co=h(Q(),1),ps="data-wp-hash";function ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&sb(document)),e.__wpStyleRuntime}function ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ps}]`))if(o.getAttribute(ps)===t)return!0;return!1}function Jd(e,t,o){if(!e.head)return;let n=ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function sb(e){let t=ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Jd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function pr(e,t){let o=ms();o.styles.set(e,t);for(let n of o.documents.keys())Jd(n,e,t)}typeof process>"u",pr("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var ab={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",pr("5f8e7aa0bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active),:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}}}");var cb={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible"};typeof process>"u",pr("e8e6a9be37",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}');var fr={link:"d4250949359b05ce__link","is-brand":"c6055659b8e2cd2c__is-brand","is-neutral":"_92e0dfcaeee15b88__is-neutral","is-unstyled":"cf122a9bf1035d42__is-unstyled","link-icon":"_0cb411afac4c86c7__link-icon"};typeof process>"u",pr("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var lb={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},en=(0,Zd.forwardRef)(function({children:t,variant:o="default",tone:n="brand",openInNewTab:r=!1,render:i,className:s,...a},d){return bt({render:i,defaultTagName:"a",ref:d,props:ye(a,{className:$(lb.a,ab["box-sizing"],cb["outset-ring--focus-except-active"],o!=="unstyled"&&fr.link,o!=="unstyled"&&fr[`is-${n}`],o==="unstyled"&&fr["is-unstyled"],s),target:r?"_blank":void 0,children:(0,Co.jsxs)(Co.Fragment,{children:[t,r&&(0,Co.jsx)("span",{className:fr["link-icon"],role:"img","aria-label":(0,Qd.__)("(opens in a new tab)")})]})})})});var tn={};At(tn,{ActionButton:()=>xu,ActionLink:()=>Eu,Actions:()=>fu,CloseIcon:()=>hu,Description:()=>lu,Root:()=>tu,Title:()=>iu});var Ao=h(de(),1);import{speak as db}from"@wordpress/a11y";var Oo=h(Q(),1),bs="data-wp-hash";function hs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&fb(document)),e.__wpStyleRuntime}function ub(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${bs}]`))if(o.getAttribute(bs)===t)return!0;return!1}function $d(e,t,o){if(!e.head)return;let n=hs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ub(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(bs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function fb(e){let t=hs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)$d(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function eu(e,t){let o=hs();o.styles.set(e,t);for(let n of o.documents.keys())$d(n,e,t)}typeof process>"u",eu("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var pb={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",eu("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var gs={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},mb={neutral:null,info:Xi,warning:Fi,success:qi,error:Ui};function gb(e){return e==="error"?"assertive":"polite"}function bb(e){if(e){if(typeof e=="string")return e;try{return(0,Ao.renderToString)(e)}catch{return}}}function hb(e,t){let o=bb(e);(0,Ao.useEffect)(()=>{o&&db(o,t)},[o,t])}var tu=(0,Ao.forwardRef)(function({intent:t="neutral",children:o,icon:n,spokenMessage:r=o,politeness:i=gb(t),render:s,...a},d){hb(r,i);let c=n===null?null:n??mb[t],l=$(gs.notice,gs[`is-${t}`],pb["box-sizing"]);return bt({defaultTagName:"div",render:s,ref:d,props:ye({className:l,children:(0,Oo.jsxs)(Oo.Fragment,{children:[o,c&&(0,Oo.jsx)(eo,{className:gs.icon,icon:c})]})},a)})});var ou=h(de(),1);var ru=h(Q(),1),ws="data-wp-hash";function vs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vb(document)),e.__wpStyleRuntime}function wb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ws}]`))if(o.getAttribute(ws)===t)return!0;return!1}function nu(e,t,o){if(!e.head)return;let n=vs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ws,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vb(e){let t=vs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)nu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function _b(e,t){let o=vs();o.styles.set(e,t);for(let n of o.documents.keys())nu(n,e,t)}typeof process>"u",_b("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var yb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},iu=(0,ou.forwardRef)(function({className:t,...o},n){return(0,ru.jsx)(Je,{ref:n,variant:"heading-md",className:$(yb.title,t),...o})});var su=h(de(),1);var cu=h(Q(),1),_s="data-wp-hash";function ys(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Rb(document)),e.__wpStyleRuntime}function xb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${_s}]`))if(o.getAttribute(_s)===t)return!0;return!1}function au(e,t,o){if(!e.head)return;let n=ys(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(xb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(_s,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Rb(e){let t=ys();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)au(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Sb(e,t){let o=ys();o.styles.set(e,t);for(let n of o.documents.keys())au(n,e,t)}typeof process>"u",Sb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Eb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},lu=(0,su.forwardRef)(function({className:t,...o},n){return(0,cu.jsx)(Je,{ref:n,variant:"body-md",className:$(Eb.description,t),...o})});var du=h(de(),1);var xs="data-wp-hash";function Rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&kb(document)),e.__wpStyleRuntime}function Tb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${xs}]`))if(o.getAttribute(xs)===t)return!0;return!1}function uu(e,t,o){if(!e.head)return;let n=Rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Tb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(xs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function kb(e){let t=Rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)uu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Pb(e,t){let o=Rs();o.styles.set(e,t);for(let n of o.documents.keys())uu(n,e,t)}typeof process>"u",Pb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Cb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},fu=(0,du.forwardRef)(function({render:t,...o},n){return bt({defaultTagName:"div",render:t,ref:n,props:ye({className:Cb.actions},o)})});var pu=h(de(),1),mu=h(Ot(),1);var bu=h(Q(),1),Ss="data-wp-hash";function Es(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ob(document)),e.__wpStyleRuntime}function Ab(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ss}]`))if(o.getAttribute(Ss)===t)return!0;return!1}function gu(e,t,o){if(!e.head)return;let n=Es(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ab(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ss,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ob(e){let t=Es();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)gu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Nb(e,t){let o=Es();o.styles.set(e,t);for(let n of o.documents.keys())gu(n,e,t)}typeof process>"u",Nb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Lb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},hu=(0,pu.forwardRef)(function({className:t,icon:o=Wi,label:n=(0,mu.__)("Dismiss"),...r},i){return(0,bu.jsx)(fs,{...r,ref:i,className:$(Lb["close-icon"],t),variant:"minimal",size:"small",tone:"neutral",icon:o,label:n})});var vu=h(de(),1);var yu=h(Q(),1),Ts="data-wp-hash";function ks(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Mb(document)),e.__wpStyleRuntime}function Ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ts}]`))if(o.getAttribute(Ts)===t)return!0;return!1}function _u(e,t,o){if(!e.head)return;let n=ks(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Mb(e){let t=ks();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)_u(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Bb(e,t){let o=ks();o.styles.set(e,t);for(let n of o.documents.keys())_u(n,e,t)}typeof process>"u",Bb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var wu={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},xu=(0,vu.forwardRef)(function({className:t,loading:o,loadingAnnouncement:n,variant:r,...i},s){return(0,yu.jsx)(sr,{...i,...o!==void 0?{loading:o,loadingAnnouncement:n??""}:{},ref:s,size:"compact",tone:"neutral",variant:r,className:$(wu["action-button"],wu[`is-action-button-${r}`],t)})});var Ru=h(de(),1);var Cs=h(Q(),1),Ps="data-wp-hash";function As(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&zb(document)),e.__wpStyleRuntime}function Hb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ps}]`))if(o.getAttribute(Ps)===t)return!0;return!1}function Su(e,t,o){if(!e.head)return;let n=As(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Hb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function zb(e){let t=As();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Su(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Db(e,t){let o=As();o.styles.set(e,t);for(let n of o.documents.keys())Su(n,e,t)}typeof process>"u",Db("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var jb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},Eu=(0,Ru.forwardRef)(function({className:t,render:o,...n},r){return(0,Cs.jsx)(Je,{ref:r,className:$(jb["action-link"],t),...n,variant:"body-md",render:(0,Cs.jsx)(en,{tone:"neutral",variant:"default",render:o})})});var Tu=h(de(),1),ku=h(Q(),1),Pu=(0,Tu.forwardRef)(({children:e,className:t,ariaLabel:o,as:n="div",...r},i)=>(0,ku.jsx)(n,{ref:i,className:$("admin-ui-navigable-region",t),"aria-label":o,role:"region",tabIndex:"-1",...r,children:e}));Pu.displayName="NavigableRegion";var Cu=Pu;var Ou=h(on(),1),{Fill:Nu,Slot:Lu}=(0,Ou.createSlotFill)("SidebarToggle");var $e=h(Q(),1),Os="data-wp-hash";function Ns(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Vb(document)),e.__wpStyleRuntime}function Fb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Os}]`))if(o.getAttribute(Os)===t)return!0;return!1}function Iu(e,t,o){if(!e.head)return;let n=Ns(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Fb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Os,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Vb(e){let t=Ns();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Iu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Wb(e,t){let o=Ns();o.styles.set(e,t);for(let n of o.documents.keys())Iu(n,e,t)}typeof process>"u",Wb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var to={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Mu({headingLevel:e=1,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:s,showSidebarToggle:a=!0}){let d=`h${e}`;return(0,$e.jsxs)(Po,{direction:"column",className:to.header,children:[(0,$e.jsxs)(Po,{className:to["header-content"],direction:"row",gap:"sm",justify:"space-between",children:[(0,$e.jsxs)(Po,{direction:"row",gap:"sm",align:"center",justify:"start",children:[a&&(0,$e.jsx)(Lu,{bubblesVirtually:!0,className:to["sidebar-toggle-slot"]}),n&&(0,$e.jsx)("div",{className:to["header-visual"],"aria-hidden":"true",children:n}),r&&(0,$e.jsx)(Je,{className:to["header-title"],render:(0,$e.jsx)(d,{}),variant:"heading-lg",children:r}),t,o]}),s&&(0,$e.jsx)(Po,{align:"center",className:to["header-actions"],direction:"row",gap:"sm",children:s})]}),i&&(0,$e.jsx)(Je,{render:(0,$e.jsx)("p",{}),variant:"body-md",className:to["header-subtitle"],children:i})]})}var nn=h(Q(),1),Is="data-wp-hash";function Ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ub(document)),e.__wpStyleRuntime}function Yb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Is}]`))if(o.getAttribute(Is)===t)return!0;return!1}function Bu(e,t,o){if(!e.head)return;let n=Ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Yb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ub(e){let t=Ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Bu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Gb(e,t){let o=Ms();o.styles.set(e,t);for(let n of o.documents.keys())Bu(n,e,t)}typeof process>"u",Gb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var Ls={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Hu({headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,children:s,className:a,actions:d,ariaLabel:c,hasPadding:l=!1,showSidebarToggle:f=!0}){let p=$(Ls.page,a);return(0,nn.jsxs)(Cu,{className:p,ariaLabel:c??(typeof r=="string"?r:""),children:[(r||t||o||d||n)&&(0,nn.jsx)(Mu,{headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:d,showSidebarToggle:f}),l?(0,nn.jsx)("div",{className:$(Ls.content,Ls["has-padding"]),children:s}):s]})}Hu.SidebarToggleFill=Nu;var Bs=Hu;var dt=h(on()),lf=h(rn()),df=h(de()),Tt=h(Ot()),uf=h(mr());import{privateApis as l0}from"@wordpress/connectors";var ju=h(Qi()),{lock:l3,unlock:No}=(0,ju.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='09e9b056ea']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","09e9b056ea"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page__file-mods-notice{margin-bottom:16px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background-color:#e7d4e4;background-image:radial-gradient(ellipse 70% 120% at 18% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 92% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 58% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:150px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background-image:radial-gradient(ellipse 70% 120% at 82% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 8% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 42% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:110px;inset-inline-end:16px;position:absolute;top:12px;width:110px}.connectors-page>p{color:#949494}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:100px}.connectors-page .ai-plugin-callout__decoration{height:75px;inset-inline-end:8px;top:8px;width:75px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .ai-plugin-callout{padding-inline-end:130px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var cn=h(on()),Ws=h(mr()),ln=h(rn()),wt=h(de()),Xe=h(Ot()),rf=h(Hs()),sf=h(Wu());var gr=h(on()),js=h(de()),Qu=h(rn()),oo=h(Ot());import{__experimentalRegisterConnector as Xb,__experimentalConnectorItem as Zu,__experimentalDefaultConnectorSettings as Kb,__experimentalApplicationPasswordConnectorSettings as qb,privateApis as Zb}from"@wordpress/connectors";var zs=h(mr()),an=h(rn()),sn=h(de()),fe=h(Ot()),Yu=h(Hs());function Ds({file:e,settingName:t,connectorName:o,isInstalled:n,isActivated:r,keySource:i="none",initialIsConnected:s=!1}){let[a,d]=(0,sn.useState)(!1),[c,l]=(0,sn.useState)(!1),[f,p]=(0,sn.useState)(s),[m,u]=(0,sn.useState)(null),g=e?.replace(/\.php$/,""),v=g?.includes("/")?g.split("/")[0]:g,{derivedPluginStatus:_,canManagePlugins:w,currentApiKey:y,currentUsername:b,hasStoredCredentials:S,hasResolvedSettings:x,canInstallPlugins:E}=(0,an.useSelect)(K=>{let J=K(zs.store),me=J.getEntityRecord("root","site")?.[t],le=typeof me=="string"?me:"",X=typeof me=="object"&&me!==null?me:void 0,pe=X!==void 0?!!X.username&&!!X.password:!!le,ue=J.hasFinishedResolution("getEntityRecord",["root","site"]),vt=!!J.canUser("create",{kind:"root",name:"plugin"}),Te={currentApiKey:le,currentUsername:X?.username??"",hasStoredCredentials:pe,hasResolvedSettings:ue,canInstallPlugins:vt};if(!e)return{...Te,derivedPluginStatus:ue?"active":"checking",canManagePlugins:void 0};let Ve=J.getEntityRecord("root","plugin",g);if(!J.hasFinishedResolution("getEntityRecord",["root","plugin",g]))return{...Te,derivedPluginStatus:"checking",canManagePlugins:void 0};if(Ve){let no=Ve.status==="active"||Ve.status==="network-active";return{...Te,derivedPluginStatus:no?"active":"inactive",canManagePlugins:!0}}let He="not-installed";return r?He="active":n&&(He="inactive"),{...Te,derivedPluginStatus:He,canManagePlugins:!1}},[e,g,t,n,r]),T=m??_,k=w,C=T==="active"&&f||m==="active"&&S,{saveEntityRecord:j,invalidateResolution:A}=(0,an.useDispatch)(zs.store),{createSuccessNotice:L,createErrorNotice:I}=(0,an.useDispatch)(Yu.store),R=K=>j("root","site",{[t]:K},{throwOnError:!0}),N=()=>{L((0,fe.sprintf)((0,fe.__)("%s connected successfully."),o),{id:"connector-connect-success",type:"snackbar"})},H=()=>{L((0,fe.sprintf)((0,fe.__)("%s disconnected."),o),{id:"connector-disconnect-success",type:"snackbar"})},P=()=>{I((0,fe.sprintf)((0,fe.__)("Failed to disconnect %s."),o),{id:"connector-disconnect-error",type:"snackbar"})},O=async()=>{if(v){l(!0);try{await j("root","plugin",{slug:v,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s installed and activated successfully."),o),{id:"connector-plugin-install-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to install plugin for %s."),o),{id:"connector-plugin-install-error",type:"snackbar"})}finally{l(!1)}}},M=async()=>{if(e){l(!0);try{await j("root","plugin",{plugin:g,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s activated successfully."),o),{id:"connector-plugin-activate-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to activate plugin for %s."),o),{id:"connector-plugin-activate-error",type:"snackbar"})}finally{l(!1)}}};return{pluginStatus:T,canInstallPlugins:E,canActivatePlugins:k,isExpanded:a,setIsExpanded:d,isBusy:c,isConnected:C,currentApiKey:y,currentUsername:b,hasResolvedSettings:x,keySource:i,handleButtonClick:()=>{if(T==="not-installed"){if(E===!1)return;O()}else if(T==="inactive"){if(k===!1)return;M()}else d(!a)},getButtonLabel:()=>{if(c)return T==="not-installed"?(0,fe.__)("Installing\u2026"):(0,fe.__)("Activating\u2026");if(a)return(0,fe.__)("Cancel");if(C)return(0,fe.__)("Edit");switch(T){case"checking":return(0,fe.__)("Checking\u2026");case"not-installed":return(0,fe.__)("Install");case"inactive":return(0,fe.__)("Activate");case"active":return(0,fe.__)("Set up")}},saveApiKey:async K=>{let J=y;try{let le=(await R(K))?.[t];if(K&&(le===J||!le))throw new Error("It was not possible to connect to the provider using this key.");p(!0),N()}catch(ne){throw console.error("Failed to save API key:",ne),ne}},removeApiKey:async()=>{try{await R(""),p(!1),H()}catch(K){console.error("Failed to remove API key:",K),P()}},saveCredentials:async({username:K,applicationPassword:J})=>{try{let le=(await R({username:K,password:J}))?.[t];if(!le?.username||!le?.password)throw new Error((0,fe.__)("It was not possible to save these credentials."));p(!0),N()}catch(ne){throw console.error("Failed to save credentials:",ne),ne}},removeCredentials:async()=>{try{await R({username:"",password:""}),p(!1),H()}catch(K){console.error("Failed to remove credentials:",K),P()}}}}var Uu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),Gu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Xu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),Ku=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 44 44",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("rect",{width:"44",height:"44",fill:"#357B49",rx:"6"}),React.createElement("path",{fill:"#fff",fillRule:"evenodd",d:"m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z",clipRule:"evenodd"})),qu=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));var{store:Qb}=No(Zb);function Ju(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"{}")}catch{return{}}}function Fs(){return Ju().connectors??{}}function $u(){return!!Ju().isFileModDisabled}var Jb={google:qu,openai:Uu,anthropic:Gu,akismet:Ku};function $b(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let o=Jb[e];return React.createElement(o||Xu,null)}var e0=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:"var(--wpds-typography-font-weight-emphasis)",whiteSpace:"nowrap"}},(0,oo.__)("Connected")),t0=({slug:e})=>React.createElement(en,{href:(0,oo.sprintf)((0,oo.__)("https://wordpress.org/plugins/%s/"),e),openInNewTab:!0},(0,oo.__)("Learn more")),o0=()=>React.createElement(Ii,null,(0,oo.__)("Not available"));function ef({isConnected:e,showUnavailableBadge:t,pluginSlug:o,isExpanded:n,isBusy:r,pluginStatus:i,actionButtonRef:s,handleButtonClick:a,getButtonLabel:d}){return React.createElement(gr.__experimentalHStack,{spacing:3,expanded:!1},e&&React.createElement(e0,null),t&&(o?React.createElement(t0,{slug:o}):React.createElement(o0,null)),!t&&React.createElement(gr.Button,{ref:s,variant:n||e?"tertiary":"secondary",size:"compact",onClick:a,disabled:i==="checking"||r,isBusy:r,accessibleWhenDisabled:!0},d()))}function tf(e){let t=e?.replace(/\.php$/,"");return t?.includes("/")?t.split("/")[0]:t}function n0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="api_key"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentApiKey:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveApiKey:S,removeApiKey:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=c==="not-installed"&&l===!1||c==="inactive"&&f===!1,k=(0,js.useRef)(null);return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:T,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:k,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(Kb,{key:g?"connected":"setup",initialValue:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),k.current?.focus()},onSave:async C=>{await S(C),m(!1),k.current?.focus()}}))}function r0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="application_password"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentUsername:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveCredentials:S,removeCredentials:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=(0,js.useRef)(null),k=c==="not-installed"&&l===!1||c==="inactive"&&f===!1;return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:k,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:T,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(qb,{key:g?"connected":"setup",initialUsername:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),T.current?.focus()},onSave:async C=>{await S(C),m(!1),T.current?.focus()}}))}function of(){let e=Fs(),t=o=>o.replace(/[^a-z0-9-_]/gi,"-");for(let[o,n]of Object.entries(e)){if(o==="akismet"&&!n.plugin?.isInstalled)continue;let{authentication:r}=n,i=t(o),s={name:n.name,description:n.description,type:n.type,logo:$b(o,n.logoUrl),authentication:r,plugin:n.plugin},a=No((0,Qu.select)(Qb)).getConnector(i);r.method==="api_key"&&!a?.render?s.render=n0:r.method==="application_password"&&!a?.render&&(s.render=r0),Xb(i,s)}}function nf(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAAQAElEQVR4AezdC3ojWW5tYflOzPbIbI/M9sh8+WdrdZ+KpiiKL5FB5KedwN7AeSFIpHRYmfX/PubXVGAqMBV4kQpMw3qRBzXbnApMBT4+pmHNq2AqMBV4mQpMw3qZR3X9RmeGqcCrV2Aa1qs/wdn/VOCNKjAN640e9hx1KvDqFZiG9epPcPY/FThWgZ1q07B2+mDnWFOBPVZgGtYen+qcaSqw0wpMw9rpg51jTQX2WIFpWMee6mhTganAU1ZgGtZTPpbZ1FRgKnCsAtOwjlVltKnAVOApKzAN6ykfy2zqcRWYlV6pAtOwXulpzV6nAm9egWlYb/4CmONPBV6pAtOwXulpve9e//Nw9P/7xL8d7Hy9aQWubFhvWrU59qMr8D+HBcPBna93rcA0rHd98q91bs3q3w9bBv7Bna93rMA0rHd86nPmqcCLVmAa1os+uF/Y9m8u6Q7rvw8bgLnDOhTiXb+mYb3rk3+tc//rYbsaVTjQP18amct4+h9hftt3BaZh7fv57v107rNg7+ec831WYBrWZyHGPHUF/vewu//6xNqg+HMRfyjMrb+edb5pWM/6ZGZfawX86Bc0qTU2/htVYBrWGz3sOepU4NUrMA3r1Z/g7H8q8EYVmIZ1h4c9U04FpgL3qcA0rPvUdWadCkwF7lCBaVh3KOpMORWYCtynAtOw7lPXmfVdKjDnfGgFpmE9tNyz2FRgKnBNBaZhXVO9GTsVmAo8tALTsB5a7llsKjAVuKYCv9uwrtn5jJ0KTAXergLTsN7ukc+BpwKvW4FpWK/77GbnU4G3q8A0rLd75L914Fl3KnB9BaZhXV/DmWEqMBV4UAWmYT2o0LPMVGAqcH0FpmFdX8OZYSowFfhrBe7GpmHdrbQz8VRgKnDrCkzDunVFZ76pwFTgbhWYhnW30s7EU4GpwK0rMA3r1hW9fr6ZYSowFfiiAtOwvijMyFOBqcDzVWAa1vM9k9nRVGAq8EUFpmF9UZiRpwKPqMCs8bMKTMP6Wb0meyowFfjFCkzD+sXiz9JTganAzyowDetn9ZrsqcBU4Bcr8NIN6xfrNktPBaYCv1CBaVi/UPRZciowFbisAtOwLqvbjJoKTAV+oQLTsH6h6LPkBRWYIVOBQwWmYR2KMF9TganAa1RgGtZrPKfZ5VRgKnCowDSsQxHmayowFXimCny9l2lYX9dmIlOBqcCTVWAa1pM9kNnOVGAq8HUFpmF9XZuJTAWmAk9WgWlYT/ZArt/OzDAV2G8FpmHt99nOyaYCu6vANKzdPdK7HOjfDrP+9yf4B/fP138efoeDma+pwP0rMA3r/jXewwqaVFjP8x8HAmIHd74eXIG3W24a1ts98pse+H8Os8HBzNdU4P4VmIZ1/xrvYQU/9v3L4SCwNqh/P2iwagdpvqYC96nANKz71HVmnQpMBe5QgXduWHco526n9B3W9tJ91fi7Pfwc7HkqMA3reZ7FM+/kXw+bc7EeDvTPV1z8jzC/TQXuWYFpWPes7sw9FZgK3LQC07BuWs7dTva/h5P91ye6YGfT2EP4eb9mZ/uowDSsfTzHe5/CHVXQqKzHbjX6YCpwtwpMw7pbaX808f8dsoN7oQOdr6nAVGBbgWlY24o8hvvEzXcnp1YTl3cq51ExTdRe7GldE6ev2vhTgbtV4KyGdbfV33diDeC7T9bE5T1LlezFntb94PRVG38qcLcKTMO6W2lPTuy/GPdfiJfED+6G6Lg8/m/Dnuxne7lOe5Y9/naNZv0HVGAa1gOKfMYSGkI4I/1XUp59f79SlFn0sRWYhvXYereaex/3PyunQT9iiePlPMbOKlOBJ67ANKzfeTiakvufVsdDmjgtPnYq8PYVmIb1Oy8B9z7uf06tLi7vVM7EpgJvVYFpWM/xuF1mB3dFz7Gr2cXOK/B6x5uG9RzPzH1VeI4dzS6mAk9YgWlYT/hQZktTganA8QpMwzpel1GnAlOBJ6zANKyLH8oMnApMBR5dgWlYj674rDcVmApcXIFpWBeXbgZOBaYCj67ANKxHV3zWe8UKzJ6fpALTsJ7kQcw2pgJTge8rMA3r+xpNxlRgKvAkFZiG9SQPYrYxFZgKfF+BRzSs73cxGVOBqcBU4IwKTMM6o0iTMhWYCjxHBaZhPcdzmF1MBaYCZ1RgGtYZRXrSFP9Wln/gD/htEwd/mTqNT4Nyj2ny+7/3sDgYh4NxNBYHcdrH4Tc8HOifL/E044hsmjgN0tj2Ko6DcfLE8EADcfmAg1w64IMXrcA0rNd5cN6Y3njrjnE4pvkHANP58iCNxQO+B3x11v84HA6c9+DO1ytWYBrW6zw1/6Df/FtZlz8vtYPLZ5iRv16BaVi//gjO3sD2zYb7F0mB30Q4aHBpfBqU68cjHPjl4iHNPy5oDljH4yBeLh7SxNN+Mr7cY+PFmpNtLf52/2suv9yx11TgF8ZOw/qFor/gkt7kKzrCJZox9xzf3GN3WIFpWK/xUN27hHbsuyIXyyBGZ/FAgzgrh7aO59PE5AQaiKfJ2WriNCiPPZabxsqBa8ebw9rAD9agmX+r0QcvVoFpWK/xwLzxvOFcGq87pof0OHtKE5MDLqpxwAMO4mksDfggjgMecBBPY2lsEKdBGouDOB5oEGdx4Ac8pLFpY1+sAtOwfuuBXb+uN/H1s9x2hm0zWH/8u+1KM9tbVmAa1ms8dj/SuEh2odyOXUSHtTGksadyjZETjuWm/e/BKc+4A/1g09iPw69j2kH+OHe8XHMF89GOjaeXx+LAD41nt5rcwYtVYBrWiz2wZbvehBoZ8IVYPNAgzuKw5vK32prLD8dy08xRHosDP5TLbrVtLg7lscbRWDzQIM7isObyaYMXrMA0rBd8aN9s2Y9lodQ4m/ZK1r7Duu80Np0f0n7ZzvK3qsA0rFtV8vHzeFP6r9+Bbwesy/lAgzgrh8YaC+t3I3SclRdoEGflbDU6TYwfaBBn5Ww1fN2nPB820ECcZiweaLDmyqGBcSunDV6sAtOwXuyBPWC73tTe9NulaNuLfhqsuY1nV13eOeONMRb4t4Q93HK+mevBFZiG9eCC33i5ay+S3efAui3fibjc3+o06625OH3VjKOZZ9V9aCB/1XC5q2YczTyrfq7vgt5YWMc076qN/2IVeIGG9WIVfdx2vSG9CaFVaRpDSI+zcuisxgDrHMXYFfLhO018m0eDrY6D2AoapNmjvUN7FcdDueLyQU762B1UYBrWDh7iHGEq8C4VmIa1vyfdJbTvNDqdi/Ww3g3JgfKe1TpT+2+/zpHGtne6HEgbu5MKTMPayYNcjuENC9sL7iXl765LaPi78MvOLZZ3dmc65/y3WG/meGAFpmE9sNhPuJQ7HhffT7i1i7fkTODy/eJJZuBzVmAa1nM+l2t21SW0S+fm4Qdv5lV/hR+dNNXt/p0jje1M6a9wrvY89swKTMM6s1AvnuZNHF7xKO092xnibNrYF6nAJduchnVJ1Z57jO8sQjt1aR3c8aS/inWe7f6dI43tLPSQNnYnFZiGtZMHuRzDhTOsl869gdkl9cOna7Bqz+g7i72H9hhnV00DU4O0sTupwDSsnTzIOcZU4B0qMA3rRZ/yiW27gAYX1aXxw3rf0wV9ec9qfeJ3bP9pbHv346NzqUHa2J1UYBrWTh7kcgwNKSR7E4e0V7LtnXU2e2fxQBvsvALTsHb+gD+P544nfEovZdo7u24cD6s+/k4rMA1rfw/WJTq4eO50/OANvtXjz2pdoLd/31HZp3OksTQQx1n89TEn+HsFpmH9vRRv6XjTw94O70w+Wdzbud7+PNOw9vcScLcDLqq/O52Lafgu75Xizu5M60X8K+1/9nqiAtOwThTnRUPerLD+SORTs+AN3dH4EH9W6zztv3PZdxrb3ukhbexOKrD/hrWTBzXHmApMBT4+pmHt71Xgwhn6TsQJXcIH9zs04AP/meE87b9z2Xca2/7pciBt7E4qMA1rJw9yOYY3LJxz6awRwDL85V1n96niOed/+cO+2wGmYb3bE//reXd21/PncJ3pnA8d/gyY316nAtOwXudZnbvTfzkkgovqg/vnix+8of+Ih9/SDu5Tf/nEr722fzaN7QDp8yNhFdmRnYa1o4d54ijexOFE2tOG2nu2jcbZtLE7rsA0rP09XN9ZhE7nniq440l/Fes82/07Rxr7KmeZfV5RgaVhXTHLDH2mCrhwhvXS2Zs7rHutEazaM/rO0v7Z9sgPac7kU8NpYlVkR3Ya1o4e5hzlLxWYS/e/lGMfZBrWPp7jeoouqNl0fljve3w3sl5Yl/9sVvM5tv80tj07kw8d2LSxO6nANKydPMjlGN6omhIk0wLt1dDe2c7F4uHVzjT7vaAC07AuKNoLDumeh33B7X/Yd/hYfqWxizzuXiswDWt/T9aFM6yXzvywvrnTnr0KPkRor76jsl/nSGNpII6z+GBHFZiGtaOHecFRvOnhgqFPPcSZfLL41Jt81Ob2tM40rD09zb+dxd0OuKj+m/L17y7c4euM14v4zsqZ1ov41zvF7PhoBaZhHS3LS4verOCN20F8ahY0s3Q+xJ/VOk/771z2ncauexeDVRt/BxWYhrWDhzhHmAq8SwWmYX3zpF8w7MIZ+k7EEVzCB/c7NOAD/5nhPO2/c9l3Gtv+6XIgbexOKjANaycPcjmGNyycc+msEcAy/OVdZ/ep4jnnf/nDvtsBpmG92xP/63nd88Bf1ddmzgPnfOjw2id9w91Pw9rfQ3cBDS6qOx0/eDNv9fizWp/4bffvHGlse0//+Y+EzTD2aSswDetpH81NN+ZNHG468YMma+/Zlo2zaWN3XIFpWPt7uL6zCJ0uzrrjSX+Utaa1gW9dFg+0r7Dm8strLJs2dscVmIa1v4frwhnWS2c8rCf26Rqs2j381mZrOCwO617pPghg24uckM6mseVqXs5kjrSxO6nA7RrWTgoyx3iaCmhIT7OZ2chzVGAa1nM8h1vuwgV1aN44u973uJyH8u5lfWJnbWh9Fg+tTXeJ7jultGPjxRrL4mCcM5kDH+yoAtOwdvQwP4/iDQve+J/SBx4+fuFXa7Pti8WBf2pbckK5bBp7avzEdlKBaVg7eZDfHMOPV+Gb1HPCP85pbbbB/BXpx+xXeV/px+YYbQcVmIa1g4e4OYILZ1gvnfnBm7wh8mDVil1jfcezzulSvPXFzC2eJk4DcTqLgzgN0tfxdHkgjrP4YEcVmIa1o4f5w6N4w/9wyFnp5tVgzkr+Iskc6yeHX6R9KV87/suJJ/C7FZiG9bv1v8fq7nbARfWp+eW4rIZTeT+NmdeFN/vTsfKNsyfAf4prx/90vb3nP9X5pmE91eO4yWY0C1h/JPKpWfCGbiE5sGrFrrHb+eyn9a1nbjZNnAbGirE4iJcrRhNPY2lAl8Pigx1VYBrWjh7mHGUqsPcKTMPa3xN24Qy+y+h0LtaD+x06Kwfwe8Ia9gTWtRaLgzjtK8hpoPtu7gAAEABJREFU/+Uan8Y2li4H0sbupALTsO77IH9jdm9YOOfS2uU4yL/3Xq0B6zo4nLPXddwp33zOdMs5T603sQdWYBrWA4s9Sz2kAu6u4LsPHR6ymVnkthWYhnXbej7DbC6gwUV1++EHb2Y6Kw/4tHvBj2fWgdZi29N3nwiKl2ucfbJpLA3SrYkPdlSBaVg7epgnjuJNHE6kPTzUnthTi4uvKPeYVuzhdha8fwWmYd2/xo9ewR0OrN9h8INYe+JD/FmtPR7bfxr7rHuffd2wAtOwbljMJ5nKJ2qwXjq7hA5tUxOQB/z0Z7TtnW2vLB7at+blU0PnShu7kwpMw9rJg5xjTAXeoQJP07DeodgPOqML6tCSx+56aC6rgV/uM1qf+NkjrPvDQ7rvsJxJDdLG7qQC07B28iCXY3jDgjdysjdwWHU+lPes1nnaP98+7TuNpQUxiI/dSQWmYe3kQX5zDPc94ZvUpwy3d3bdIB5WffydVmAa1v4erAtnWC+d+cEb3KlZecCnPQY/X8XFevvvOyx7TmObVRxn08bupALTsHbyIOcYf6mAZrZ+SvqX4JDXrcA0rNd9dl/t3N0NuKj+Kocux8U04HvBXs+1l+dz1TmmYV1Vvqcc7AIa1h+J/JWY4A3dxuXAqhV7Jus87d9+7c2e01ga0OWw+OBXK3Dbxadh3baeM9tUYCpwxwpMw7pjcR8wtbsaaCm+S/SQHmflpL+K9R2Ti3Ro/6zzhM4iJ8hJH7uDCkzDeu2H2Bvz0lNoBHDp+EeO03zCqXXLYU/lTewFKzAN66kf2rebc08D3yZ+kaBZXTP+i2lHngrcpwLTsO5T10fN6jIaWk/zwUN6nJWTzm457dmgsdo7tF8WD+05zspJH7uDCkzD2sFD3BzBmzQUirNpr2btPbT3OHtKKzb2xSswDet1H6A7Gt95AL+T4CFNPKTtwXZOtvPwgzNv9VUr9gx29nBGBaZhnVGkJ03xxvNXVoBvmyweaEC/9oLePM8EZ+qc63/Vnsa2Xw0MB+PSx75YBaZhvdgDm+1OBd65AtOwXvfpu7cJ6ynS2HTfYbiEhrQ9WH+tKHQe515Bx8vj0wYvWIG9NKwXLP3VW/bG04BAQzLhqtFpQQzir26dxbmB33mcO6SJywN++tgXq8A0rBd7YMt23cWERf5IYz/u+Mv8K1rqmFbsEfbY+se0R+xl1rhxBaZh3bigd5rOhfn2r6B4E9LBdw6WXjU67RZo3tYxZ5p1XGbTAA84GNf+jaMBH/iXwFhzs41vbTadxUF+uXScTRv7xBWYhvXED+fJtuZNvX4ad4vtmVMT+dFcS7Lxa7NcQme5144/a5FJul0FpmHdrpb3nMm/beXuBe65zqm5u7Q+lXMqZu+w5vjuZv2nYdbYOb753Fex5+Rvc6x/zfjtfMPvXIFpWHcu8I2m743lzdWUNG924NO9cfFAuwXMaw22+fDWWfeVxq65cmCdo/il1lywjrduKLbulb/ml7Nq4z9pBaZhPemDmW1NBaYCHx8fmyJMw9oU5AmoexXfBbBtpwtrNl2O+x9IK/8aay5Y57CGtcG6YiwO4jTAAw7iacbRrEGHNPq1MJc5oblop9Zfcxsz9gkrMA3r+R6KS2Twhv5ud3JCubg3aPyn1nhvYPanY3+abw245WW+ucwJ3+1HTvgud+JPUIFpWE/wEDZb6IJ9I59N3cnA2QM2ica6YGc3oaFTgd+twDSs363/sdV9d+Rymi2OhxqJ+FYr/285sZ9ZY829jtLAtmvJSRMvP41NE8fB/HQWB3HaLWAuc0LzrXu1Lp2VE2iDJ6/ANKwnf0Cf2/PmCp/SH5PG/hHu9Jv5V7TMJZoxp8YXu9SaP6xzpLHp/JA29okrMA3r+R6O7wZCu4uz7lzS+RC/hbVGaD5rpPHpbBpLA37AYc3lbzX5tHvBmtYAfuvgIU08jZ8+9gkqMA3rdx+CNwS0C74L95AeZ9PkuhwHfvq11hqhueJsa7E4uOg+lntMM47OGgvrePqtz2RO6wB/uz6dBvyAvwjeY5vTsH73OXvzwLFd+FHlmH5P7au93HPNY3PbBxyLjfbGFZiG9bsPvx891l1oVODTwnQ8rFoXxmLp11qX1qG57MUakMYP4sf0NPFy0+Ks+Kr7L9XVJu1aa43OxG8+fkizl2O5xcf+YgWmYf1i8Y8s7c1TE1rfsGmsnIbyIX6tNZd1Q/Ph1gY+XS4OaXQ84CCexqd9NV7s1rCWdYFvfrY9sTSQE/DBE1VgGtafh/FUv/lRKLSxOJt2L2uN0Bpx9pj2lX4q15gV5T7SHlv/mPbIPc1aJyowDetEce4c8qf4uX9dxCV08IZqa3yIX2vN1Tps87mExsG+6WuuOA3kBBzE04yjsWniNDA/ncVvAWtVa745WesEGsRZOTTg33JP5hz8sALTsH5YsCdL9yaCJ9vWVdvxiaEzsVdNdOPBmirceNqZ7icVmIb1k2rdNtcdSrh0Zn/iw6Xjn3GcS291YW+5P3PCpXO6iF/vui6dZ8ZdUYFpWFcU78qh3jzeAFDTofmELLREnJWTfmtrbmuE5rfHtPbKpomXm8amieNgHJ3FQZwGdJzFbwHnMifwzclaO9AgzsqhAR/4g1+qwDSsXyr8LDsVmAr8vALTsH5es0tGuJPxHQM0nu9iF8TpLB5oEGfl0G453lzmNLc1Ag3E0+RsNXEalMfiII7DT8Yfy01jzResA3FWDu3Y+mJygjyIs3Jo63g+bfALFfhpw/qFLe5iSZe1sF4k870hQgeNs6c0MTkBhziLAz/gIc1etppYmjge0uPiW00sTRwP6XHxrSaWJo6H9Dh7ShOTE3CIszjwAx7S7CVt7IMrMA3rwQWf5aYCU4HLKzAN6/La/WSky16XuGzj+tSJ1mUui4dy46wcuh9NcEhj8SAP4qwc2jreXmhicgINxNPk0I6Np5fH4nDp+NY6Nl7MGsE6EGfl0Na9prFygjyIs3Jo63h7oQ1+oQLTsH6h6J9LejOs+JQ/ztU+Dr/OzT2Wdxj+cUz/u/bxj1+rxi/CD+dq8s/NPZZ3yXjzGBdwiLM48AMe0ti0sQ+uwDSsxxTcn9DBXYhV2a1GT2Nx4AfjaOxWo6exOPCDcTQ2jaUBP+Cw5vK3mnwa8AMOxqTxaZDG4sAP5bJbbZuLQ3mscTQWBz4N8IBDnC2XxYM8iLP44M4VmIZ15wJ/Tu/CPXxKH3HWG4LO4oEGcRaHNZe/1dZcfpAHxqR1kbxqYvKAH+TQ2LTG09NYOV9p3uRy4KvxxoKc0JxsGisP+AGHNZe/1eTTgB9wMCbtu73KH9ypAtOw7lTYN53WG/tNj/7ix36R7U/DesyDcu+xwqr+6kkaHtLYn2ryjQs4xFkc+MFeaJDG4iCOAw78IL7VitHFV06Ls+I0wAMO4luNnsbiwA84xFk84OFczV7KdQEf0sbesQLTsO5Y3GVqnziFZD8SpfHp3jxpLA34QQ6NTbv3ePOfWkvcnqA8FgdxHPi0Y/unywlyaMak8WliaSwN+EEOjU271XjzmivggztXYBrWnQv8Ob0flcKn9BHPfnz+irOf0gc/fCy/0thkfjiliZXH4sAPOMSzNIizOPADDvEsDeIsDvyAQzxLgziLAz/gIY39qSbfuIBDnMUHd67ANKxbFPgfc3jh9u8u+ZO3iL/mEeTQXeJuNbE0Vh7wgxya+beaWBorD/hBDm0dby80sfJYGojjIId2bDxdTsDh0vGtdWy8WOuw1gF+kENb95rGlsfKA36QQ1vH2wtNrDyWFsTyx96wAtOwbljMmWoq8FkBDUyT+6RjblWBaVi3quQ/5nFfEv6hjvdOFfC3GrwG3unMDznrNKzbltmLtMtdfrN7AYd0eWn9aSyWxp4ab4wc4Mu9dLy9nBovbh1oLRYHceMBDziIpxlHY9PEaZDGOg9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4P/y8fFhLzSxNJa2Qnzl49+gAtOwblDEmWIqMBV4TAWmYV1XZ5er0Cz+JHZ/Afx0PJQvvtXE0thHjbcXa321vrj9gBy5q8angZyAg3jaT8Yfy01jm5O1DvCDHNqx9cXKY+UBP8ihreP5NLHyWBqI4yCHNrhRBaZhXV5IL0Yvyj41aiY6rH+FAw/liaex6fxwShMrj8WBH3CIs3jAwV62Gj1NHA/pcfGtJpYmjof0uPhWE0sTx0N6nD2lickJOMRZHPgBD2n2stXE0sRxSBt7owo8uGHdaNfPM417ivW/fH6enc1OpgI7rMA0rMsfqmblAtaPAM1C669qsOn8IIeu0W01epq5cUhjG8/iwJcHOPxkvHxjgR9wOLZXa5bHygN+wOHS8db4ajy9dVgc+KHx7Fbb5uJQHmscjcUDDeIsDny1B+NogxtVYBrWjQr5OY0XqAYG/E/5Aw8fn7/ibLksDvzP1A88fHz+Ek/jk9mtRk9jcTiWu2prLj/I+Wo8vTwWB364x3hzWwf4AQdrpvG3mhgN+AEHY9L4W02MBuIBH9ywAtOwLi+mOwovVOCbicUDDeKsHBqLA58GeMAhzpbL4sCXB3jAIc6Wy+JBHsRZHNZc/lZbc/kA8sAYHPhbjU4DfjiWm7bNxaGxbLksDnx5gAcc4my5LB7kQZzFYc3lb7U1V2zwgwpMw/pBsTapLtvD+sJMc/nakDT2mHZs/Fe5jTdGDvDpLB5oEGdxWHPb66qtufwg56vx9PJYHPjhHuPNbR3gBxysmcbfamI04AccjEm7Za3MPfhBBaZh/aBYkzoVmAr8bgWmYV1efxfJ27uKOCve7HhIE99qYmksDvyAQ5zFAx7O1ezlVK74qTnFHz3eeu2JxYEfcIizeMDDudotz9qaL28fdYBpWJdX2l2ET4KAbyYvfhzS6HiQQxNP49PE0lga8IMcGpt27/HmP7WWuD1BeSwO4jjwacf2T5cT5NCMSePTxNJYGvCDHBqbdu/x5j+1lrg9DS6owDSsC4r2OcS9xopP+eMSzZiPz1/88Cl9xNmP5RcekuPsKU1MTsAhzuLAX0GDSzRjjAV+wCHO4sBfQYNztZ/kHpvz0vHmMhb4AR9cUIFpWOcVzZ+K/qt2thEuYWnghUgXx0GcBngoV3yriaWxxgI/yKGta6Wx5bHygB/k0Nbx9kITK4+lgTgOcmjHxtPlBBwuHd9ax8aLtQ5rHeAHObR1r2lseaw84Ac5tHW8vdDEymNpII6DHNqx8fTBDyswDeu8gvlkyIuPPW/ED7Im9a0r4B98fOsC/OTw07DOq5aLVvcg7HkjJmsqcF4Fjv3TNOeNfMOsaVjnPXTf0rtIZRuBe7FBOouDeLl40Pjo4mnG0cTSWBrwgxyaMWl8mlgaSwN+kEMzJs1eaGJpLA3EcTCOxuIgTgM84CCeZhyNTROnQRprPzRxHIyjieGBBnFWDs0YHPg0MTzQIM7KoRmDg73QxPBAA/E042hsmjhtcEEFpmFdUFjYef8AAA5ZSURBVLQZMhW4uAIz8KoKTMP65/K5q4I14gI1FPOn5ilNvDnKY0+NF5MTHjW+vX61vnh7kmNfq8anQXksDuI4/GT8sdw01nzBOhBn5dCOrS8mJ8iDOCuHto7n08TkBBqIp8nZauI0KI/FB99UYBrWPxfIiwd6scngBxxcwKexNOCDOA54wEE8jaUBP+AQZ/GAh59q8htrLziksTiI44EGcXEc0lgcxPFAg7g4DmksDuJ4oEGcxYEf8JDG/lSTbxzYCw54wEE8jaUBH8RxwAM++KYC07C+KdCEpwJTgeepwGs1rMfUrctRl6qt6N84Cuk+MdxqYmnsT8fLNw7Wy1k8WEMeu9XoaSwO/GAcjU1jacAPOFx6VmPh0vH2+NV4evtkceCD+jWepQFfHuABhzhbLosHeRBncfjJWe0xGDv4pgLTsL4p0GfYvUP4lD7irBf0x+EXiwP/IP35wsMf4fBbnC2XxYF/SPvzhYc/wuE38TT+Qfpg09iPz1/88Cl9rLn8j8Mvtjz2IP354gc5RHar0dNYHPjBOBq71ehpLA78YByNTWNpwAdxHPg04G81Og34AQdj0vhbTYwG/HAsN00uP+CDbyowDeuvBXKf0IuNLcoPcujsVqOnsTjwg3E0dqvR01gc+ME4GrvV6GksDvxgHI1NY2nADzisufytJp8G/ICDMWl8GqSxOPBDuexW2+biUB5rHI3FgU8DPOAgnsbfamI04AccjEnjbzUxGvADDsYEfLBUYBrWUoyD64Xir1bAejmKh0Pan684axzRiw+HS8abx9hgToizOKy5/K225vKDPDAmrb2umpg84Ac5NDat8fQ0Fgd+MI72Ta0+5MsDfmg8m8bKA37AYc3lbzX5NOAHHIxJ66yrJiYP+EEOjU1rPD2NxUGuD30AHywVmIa1FONM17fwZ6ZO2gtWQMN4wW2/x5anYf31OWtGweVp0TQ2TRyHNH4QP6aniZebxqaxOPADDnEWD3g4V7OXU7nip+YUf/R467UnFgd+wCHO4gEP52r3Pqv9uMSH9jT2swLTsD4L8Wm8WPrUxo8sn/Kf/z15epp4Gp9+zng5co05NV5MHvBD49k0c8lbNTEa8IMcGpv23XjxU7ni5oTyWBzEceDTjq1PlxPk0IxJ49PE0lga8IMcGpt27/HmP7WWuD1BeSwO9ioH8HfA2WechvXPpfIjQSgaZ49pX+nHco9pl4w3j3EBhziLAz/gIY09V/tJ7rE5f2O8fVg34BBnceAHPKSx52o/yT025zq++Fvbd29Y/hSDXgR8l52wXoTioVzxtF5Y54wv99h4seZkW4sf5NDXtdLY8lh5wA9yaOt4e6GJlcfSQBwHObRj4+lyAg6Xjm+tY+PFWoe1DvCDHNq61zS2PFYeHFtrHS8u79rx5rBuwMH8adagDT4r8M4Ny4vBi2P91OazLGOmAlOBZ6zAOzcsz8PFJvBfFbPv21bAH2S3nXFmu1kF3rlhdbnJVlDf+vdXc9aL0DS2XHEcjKOzOIjTAA+tJ55mnDyxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaZDG2g9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4GAvNDE80EA8zTgamyZOgzQWB3EcjKMNPivwzg3rswRjpgJTgVepwDs3LH96bS83V43fcyyPTRPHoR8jVo1frpxwLDeNLY991Pj2+tX64vYDcuxr1fg0kBNwEE/7yfhjuWlsc7LWAX6QQzu2vlh5rDw4lrtqfHnXjjeHdQMO5k+zBg3o/3lwVu1A3+vrnRuWy3YPP/Tk4+JbTSxNHA/pcfGtJpYmjof0OHtKE5MTcIizOPADHtLsZauJpYnjIT0uvtXE0sTxkB4X32piaeJ4SI+zpzQxOQGHOIvDuhYe5ID4VqOnieMhPS6+1cTSxPGQ7gMioKe9nX3nhvV2D3sOfFYF3rohnFWhX0x654blr1j4hBBcoHoMLB5oEGdxuHS8Nb4aT7dGwCHONp7FgS8P8IBDnC2XxYM8iLM4XHpWY+HS8fb41Xi6PQYc4i6vG8+m8+VBGosDP5TLprHygB9wuPSsxsKx8XSX8OBHQ/wt8VYNa/OEPfjgBSnMbjV6GosDPxhHY7caPY3FgR+Mo7FpLA34AYc1l7/V5NOAH3AwJo2/1cRowA/HctO2uTg0li2XxYEvD/CAQ5wtl8WDPIiL48Df6qsmJg/4AYc1l7/V5NOAH47lpm1zcWgsu+aKvT3epWH5Nj/00HEvCuDTWTzQIM7KobE48LcanQb8cCw3bZuLQ2PZclkc+PIADzjE2XJZPMiDOIvDmsvfamsuP8gDY9L4W02MBvxwLDdtm4tDY9lyWRz48gAPOIin8beaGA34AQdj0vhbTYwG/ICDMWn8rSZGe0u8S8PqUxe2B+0CM6wvjDSXn8dyj2n3GG8fx9ZKs6Yc4NNZPNAgzuKw5nbWVVtz+UHOV+Pp5bE48MM9xpvbOsAPOFgzjb/VxGjgNYIDDsbg8Ey1sre3wrs0rLd6qHPYj4+PKcIuK/AuDauLUbYH6XLTHQGk8YP4MT1NvNy0OCt+TE8TlwdpLB5wiLM48AMe0thzNXs5lStuPjiWJ35MTxM3FtL4QfyYniZebhqbxuLADzjEWTzg4VzNXk7lip+aU/xW45vnbey7NCw/94ceLu6TJODTvdBwSKPjQQ5NPI1PE9tq9DRWDs0YHPg0MTzQIM7KobE43Hu8+a0D/K/Wp8sJOBiTxqcd2z+9PFYOzRgc+DQxPNAgzsqhsTjce7z5rQP8r9anywk4GJPGpx3bP/3t8C4N6+0e7Bx4KrDHChxvWPs7qYtU6E8sJ8T/7+BAOouD+CH85wsPLmCJ4mnG0dg0cRqksafGi8kJxkKclUNb1+LTxOQEGsRZOTRjcGivYniQB+JpxtHYNHEapLE4iONgHI3FQZwGeLAfmniacTSxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaW+Jd2lYXlzQJzxv+bDn0FOBV6/AuzQsdwCwXni++rOb/U8F3q4C79KwtpeYHjTNX3UA33LTWBzEaYAHjY8mnmYcjU0Tp0Eae2q8mJxgLMRZObR1LT5NTE6gQZyVQzPmXz4+PmjtVQwP8kA8zTgamyZOgzQWB3EcjKOxOIjTAA/2QxNPM44mlsbSgB/k0IxJ49PE0lga8IMcmjFp9kITS2NpII6DcTQWB3Ea4AEH8TTjaGyaOO0t8S4N6y0f7hx6KrC3Crxzw/KnlgtMcL/l2a4anwZywrHcNLa8a8ebx9rAD9agmX+riaWx8oAf5NDW8XyaWHksDcRxkLPVxGkgJ+AgnvaT8cdy09jmZK0D/CCHdmx9sfJYecAPcmjreD5NrDyWBuI4yNlq4jSQE3AQT2s8nQ/8t8U7NywX8F4AsL4AcBBPx0OaeBpLZ4M4DdJYHMTxQIM4iwM/4CGN/akm3ziwFxzwgIN4GksDPojjgAccxNNYGvBBHAc84CCextKAH3CIs3jAw081+Y21FxzSWBzE8UCDuDgOaSwO4niggb8WpJHR8Uvw8mPeuWG9/MObA7xVBXxg5N7srQ69Pew7Nyx/TccFJvRCYOmhesVZOXQvIBzSWDzIgzgrh3ZsPF1OwCHONp7FgS8PcHAuHPBQLpvGygN+wOHYXh8x3hpfrU9vnywO/NB4dqttc3EojzWOxuKBBnEWh3vUyrx+VPRM7QV/S7xzw/LgQw8f98IAfjoejmnlsuXxj+Ue08plG88ey01bc/l01jjg0wAPOIin8beaGA344Vhu2jYXh8ay5bI48OUBHnCIs+WyeJAHcRaHNZe/1dZcfpAHxqTxt5oYDfjhWG7aNheHxrJrrtjb450b1rGH735gRTlePEGczp7SxOQBPxhHY7caPY3FgR+Mo7FbjZ7G4sAPxtHYNJYG/IDDmsvfavJpwA84GJPG32piNOCHY7lp21wcGsuWy+LAlwd4wCHOlsviQR7EWRzWXP5WW3P5QR4YE/DBUoFpWEsxDq4XiotNcMl5kP588cMf4fBbnDXuIH2wOLg8/fj8hYdP6SPOGkdn8UCDOIvDmsvfamsuP8gDY9La66qJyQN+kENj0xpPT2Nx4AfjaGzatePNY07gBxzWtfhbTT4N+AEHY9La66qJyQN+kENj0xpPT2NxkOv1B3za4LMC07A+CzFmKjAVeP4KTMP66zNyZxBcnhZNY9PEcUjjB/Fjepp4uWlsGosDP+AQZ/GAh3M1ezmVK35qTvFHj7dee2Jx4Acc4iwe8PAX7UDoB/Pnix/ufVbruMQH/p8NzG9/q8A0rL/Vod+9QHwSA+4W0vEghy6exqeJbTV6GiuHZgwOfJoYHmgQZ+XQWBzuPd781gH+V+vT5QQcjEnj047tn14eK4dmDA58mhgeaBBn5dBYHO493vzWAf5X69PlBBzs1TjAB0sFpmEtxRh3KjAVeO4KTMN67ufzTLv7yQXwT3Kf6YyzlyevwF0a1pOfebZ3ugKajX8sbpvlkyyxVceP/ejiE641b/ypwE0qMA3rJmXc1STuUPxTJttDuWsRW3X8WMM6Nn4dN/5U4KIKTMO6qGwzaCowFfiNCkzD+o2q72nNOctU4IEVmIb1wGLPUlOBqcB1FZiGdV39ZvRUYCrwwApMw3pgsWepqcBrV+D3dz8N6/efwexgKjAVOLMC07DOLNSkTQWmAr9fgWlYv/8MZgdTganAmRWYhnVmoa5PmxmmAlOBayswDevaCs74qcBU4GEVmIb1sFLPQlOBqcC1FZiGdW0FZ/xU4J8rMMqdKjAN606FnWmnAlOB21dgGtbtazozTgWmAneqwDSsOxV2pp0KTAVuX4H/DwAA//9sB2hHAAAABklEQVQDAB9QlitZA9bLAAAAAElFTkSuQmCC",width:"248",height:"248",style:{mixBlendMode:"multiply"}})))}var i0="ai",s0="ai-wp-admin",Vs="ai/ai",a0="https://wordpress.org/plugins/ai/",Ys=Object.values(Fs()),c0=Ys.some(e=>e.type==="ai_provider"),af=[];for(let e of Ys)e.type==="ai_provider"&&e.authentication.method==="api_key"&&af.push(e.authentication.settingName);function cf(){let[e,t]=(0,wt.useState)(!1),[o,n]=(0,wt.useState)(!1),r=(0,wt.useRef)(null);(0,wt.useEffect)(()=>{o&&r.current?.focus()},[o]);let i=(0,wt.useRef)(Ys.some(S=>S.type==="ai_provider"&&S.authentication.method==="api_key"&&S.authentication.isConnected)).current,{pluginStatus:s,canInstallPlugins:a,canManagePlugins:d,hasConnectedProvider:c}=(0,ln.useSelect)(S=>{let x=S(Ws.store),E=!!x.canUser("create",{kind:"root",name:"plugin"}),T=x.getEntityRecord("root","site"),k=i||af.some(A=>!!T?.[A]),C=x.getEntityRecord("root","plugin",Vs);return x.hasFinishedResolution("getEntityRecord",["root","plugin",Vs])?C?{pluginStatus:C.status==="active"?"active":"inactive",canInstallPlugins:E,canManagePlugins:!0,hasConnectedProvider:k}:{pluginStatus:"not-installed",canInstallPlugins:E,canManagePlugins:E,hasConnectedProvider:k}:{pluginStatus:"checking",canInstallPlugins:E,canManagePlugins:void 0,hasConnectedProvider:k}},[]),{saveEntityRecord:l}=(0,ln.useDispatch)(Ws.store),{createSuccessNotice:f,createErrorNotice:p}=(0,ln.useDispatch)(rf.store),m=async()=>{t(!0);try{await l("root","plugin",{slug:i0,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin installed and activated successfully."),{id:"ai-plugin-install-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to install the AI plugin."),{id:"ai-plugin-install-error",type:"snackbar"})}finally{t(!1)}},u=async()=>{t(!0);try{await l("root","plugin",{plugin:Vs,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin activated successfully."),{id:"ai-plugin-activate-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to activate the AI plugin."),{id:"ai-plugin-activate-error",type:"snackbar"})}finally{t(!1)}};if(!c0||s==="checking"||s==="active"&&i&&!o||s==="inactive"&&d===!1)return null;let g=s==="active"&&!c,v=s==="active"&&c&&(!i||o),_=s==="not-installed"||s==="inactive",w=s==="not-installed"&&a===!1,y=()=>v?(0,Xe.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):g?(0,Xe.__)("The AI plugin is installed. Connect an AI provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,Xe.__)("The AI plugin can use your AI connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),b=()=>s==="not-installed"?{label:e?(0,Xe.__)("Installing\u2026"):(0,Xe.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:m}:{label:e?(0,Xe.__)("Activating\u2026"):(0,Xe.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:u};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,wt.createInterpolateElement)(y(),{strong:React.createElement("strong",null),a:React.createElement(cn.ExternalLink,{href:a0})})),!w&&(_?React.createElement(cn.Button,{variant:"primary",size:"compact",isBusy:e,disabled:b().disabled,accessibleWhenDisabled:!0,onClick:b().onClick},b().label):React.createElement(cn.Button,{ref:r,variant:"secondary",size:"compact",href:(0,sf.addQueryArgs)("options-general.php",{page:s0})},(0,Xe.__)("Control features in the AI plugin")))),React.createElement(nf,null))}var{store:d0}=No(l0);of();function u0(){let e=$u(),{connectors:t,canInstallPlugins:o,isAiPluginInstalled:n}=(0,lf.useSelect)(c=>{let l=c(uf.store),f=l.getEntityRecord("root","plugin","ai/ai");return{connectors:No(c(d0)).getConnectors(),canInstallPlugins:l.canUser("create",{kind:"root",name:"plugin"}),isAiPluginInstalled:!!f}},[]),r=t.filter(c=>c.render),i=Array.from(new Set(t.filter(c=>c.type==="ai_provider").map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c))).sort(),s=new Set(t.filter(c=>c.plugin?.isInstalled).map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c));n&&s.add("ai");let a=["ai",...i].filter(c=>!s.has(c)),d=r.length===0;return React.createElement(Bs,{title:(0,Tt.__)("Connectors"),subTitle:(0,Tt.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${d?" connectors-page--empty":""}`},a.length>0&&(e||!o)&&React.createElement(tn.Root,{intent:"info",className:"connectors-page__file-mods-notice"},React.createElement(tn.Description,null,e?(0,Tt.__)("Plugins cannot be installed here due to your site configuration. Install them manually using your normal deployment workflow."):(0,Tt.__)("You do not have permission to install plugins. Please ask a site administrator to install them for you."))),d?React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(dt.__experimentalHeading,{level:2,size:15},(0,Tt.__)("No connectors yet")),React.createElement(dt.__experimentalText,{size:12},(0,Tt.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(dt.Button,{variant:"secondary",href:"plugin-install.php",__next40pxDefaultSize:!0},(0,Tt.__)("Learn more"))):React.createElement(dt.__experimentalVStack,{spacing:3},React.createElement(cf,null),React.createElement(dt.__experimentalVStack,{spacing:3,role:"list"},t.map(c=>c.render?React.createElement(c.render,{key:c.slug,slug:c.slug,name:c.name,description:c.description,type:c.type,logo:c.logo,authentication:c.authentication,plugin:c.plugin}):null))),o&&!e&&React.createElement("p",null,(0,df.createInterpolateElement)((0,Tt.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function f0(){return React.createElement(u0,null)}var p0=f0;export{p0 as stage}; +var wf=Object.create;var _r=Object.defineProperty;var vf=Object.getOwnPropertyDescriptor;var _f=Object.getOwnPropertyNames;var yf=Object.getPrototypeOf,xf=Object.prototype.hasOwnProperty;var Re=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),At=(e,t)=>{for(var o in t)_r(e,o,{get:t[o],enumerable:!0})},Rf=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of _f(t))!xf.call(e,r)&&r!==o&&_r(e,r,{get:()=>t[r],enumerable:!(n=vf(t,r))||n.enumerable});return e};var h=(e,t,o)=>(o=e!=null?wf(yf(e)):{},Rf(t||!e||!e.__esModule?_r(o,"default",{value:e,enumerable:!0}):o,e));var Ot=Re((g0,Gs)=>{Gs.exports=window.wp.i18n});var de=Re((h0,Ks)=>{Ks.exports=window.wp.element});var z=Re((w0,qs)=>{qs.exports=window.React});var Q=Re((E0,$s)=>{$s.exports=window.ReactJSXRuntime});var Mt=Re((Ah,Ta)=>{Ta.exports=window.ReactDOM});var Mc=Re(Ic=>{"use strict";var wo=z();function Tm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var km=typeof Object.is=="function"?Object.is:Tm,Pm=wo.useState,Cm=wo.useEffect,Am=wo.useLayoutEffect,Om=wo.useDebugValue;function Nm(e,t){var o=t(),n=Pm({inst:{value:o,getSnapshot:t}}),r=n[0].inst,i=n[1];return Am(function(){r.value=o,r.getSnapshot=t,ri(r)&&i({inst:r})},[e,o,t]),Cm(function(){return ri(r)&&i({inst:r}),e(function(){ri(r)&&i({inst:r})})},[e]),Om(o),o}function ri(e){var t=e.getSnapshot;e=e.value;try{var o=t();return!km(e,o)}catch{return!0}}function Lm(e,t){return t()}var Im=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?Lm:Nm;Ic.useSyncExternalStore=wo.useSyncExternalStore!==void 0?wo.useSyncExternalStore:Im});var ii=Re((w1,Bc)=>{"use strict";Bc.exports=Mc()});var zc=Re(Hc=>{"use strict";var Dn=z(),Mm=ii();function Bm(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Hm=typeof Object.is=="function"?Object.is:Bm,zm=Mm.useSyncExternalStore,Dm=Dn.useRef,jm=Dn.useEffect,Fm=Dn.useMemo,Vm=Dn.useDebugValue;Hc.useSyncExternalStoreWithSelector=function(e,t,o,n,r){var i=Dm(null);if(i.current===null){var s={hasValue:!1,value:null};i.current=s}else s=i.current;i=Fm(function(){function d(m){if(!c){if(c=!0,l=m,m=n(m),r!==void 0&&s.hasValue){var u=s.value;if(r(u,m))return f=u}return f=m}if(u=f,Hm(l,m))return u;var g=n(m);return r!==void 0&&r(u,g)?(l=m,u):(l=m,f=g)}var c=!1,l,f,p=o===void 0?null:o;return[function(){return d(t())},p===null?void 0:function(){return d(p())}]},[t,o,n,r]);var a=zm(e,i[0],i[1]);return jm(function(){s.hasValue=!0,s.value=a},[a]),Vm(a),a}});var jc=Re((_1,Dc)=>{"use strict";Dc.exports=zc()});var $t=Re((X2,md)=>{md.exports=window.wp.primitives});var Rd=Re((g4,xd)=>{xd.exports=window.wp.theme});var Qi=Re((b4,Sd)=>{Sd.exports=window.wp.privateApis});var on=Re((q5,Au)=>{Au.exports=window.wp.components});var rn=Re((a3,zu)=>{zu.exports=window.wp.data});var mr=Re((c3,Du)=>{Du.exports=window.wp.coreData});var Hs=Re((u3,Fu)=>{Fu.exports=window.wp.notices});var Wu=Re((f3,Vu)=>{Vu.exports=window.wp.url});function Xs(e){var t,o,n="";if(typeof e=="string"||typeof e=="number")n+=e;else if(typeof e=="object")if(Array.isArray(e)){var r=e.length;for(t=0;te();function Y(e){let t=Se(kf).current;return t.next=e,Tf(t.effect),t.trampoline}function kf(){let e={next:void 0,callback:Pf,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function Pf(){}var Js=h(z(),1),Cf=()=>{},D=typeof document<"u"?Js.useLayoutEffect:Cf;var hn=h(z(),1),Af=hn.createContext(void 0);function so(){return hn.useContext(Af)?.direction??"ltr"}function Of(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set("code",n.toString()),r.forEach(s=>i.searchParams.append("args[]",s)),`${t} error #${n}; visit ${i} for the full message.`}}var Nf=Of("https://base-ui.com/production-error","Base UI"),Pe=Nf;var Wt=h(z(),1);function xr(e,t,o,n){let r=Se(ta).current;return Lf(r,e,t,o,n)&&oa(r,[e,t,o,n]),r.callback}function ea(e){let t=Se(ta).current;return If(t,e)&&oa(t,e),t.callback}function ta(){return{callback:null,cleanup:null,refs:[]}}function Lf(e,t,o,n,r){return e.refs[0]!==t||e.refs[1]!==o||e.refs[2]!==n||e.refs[3]!==r}function If(e,t){return e.refs.length!==t.length||e.refs.some((o,n)=>o!==t[n])}function oa(e,t){if(e.refs=t,t.every(o=>o==null)){e.callback=null;return}e.callback=o=>{if(e.cleanup&&(e.cleanup(),e.cleanup=null),o!=null){let n=Array(t.length).fill(null);for(let r=0;r{for(let r=0;r=e}function Rr(e){if(!ra.isValidElement(e))return null;let t=e,o=t.props;return(ao(19)?o?.ref:t.ref)??null}function Bo(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}function Nt(){}var I0=Object.freeze([]),be=Object.freeze({});function ia(e,t){let o={};for(let n in e){let r=e[n];if(t?.hasOwnProperty(n)){let i=t[n](r);i!=null&&Object.assign(o,i);continue}r===!0?o[`data-${n.toLowerCase()}`]="":r&&(o[`data-${n.toLowerCase()}`]=r.toString())}return o}function sa(e,t){return typeof e=="function"?e(t):e}function aa(e,t){return typeof e=="function"?e(t):e}var Sr={};function ye(e,t,o,n,r){if(!o&&!n&&!r&&!e)return wn(t);let i=wn(e);return t&&(i=Ho(i,t)),o&&(i=Ho(i,o)),n&&(i=Ho(i,n)),r&&(i=Ho(i,r)),i}function ca(e){if(e.length===0)return Sr;if(e.length===1)return wn(e[0]);let t=wn(e[0]);for(let o=1;o=65&&r<=90&&(typeof t=="function"||typeof t>"u")}function Er(e){return typeof e=="function"}function da(e,t){return Er(e)?e(t):e??Sr}function zf(e,t){return t?e?(...o)=>{let n=o[0];if(fa(n)){let i=n;zo(i);let s=t(...o);return i.baseUIHandlerPrevented||e?.(...o),s}let r=t(...o);return e?.(...o),r}:ua(t):e}function ua(e){return e&&((...t)=>{let o=t[0];return fa(o)&&zo(o),e(...t)})}function zo(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Tr(e,t){return t?e?t+" "+e:t:e}function fa(e){return e!=null&&typeof e=="object"&&"nativeEvent"in e}var kr=h(z(),1);function Ce(e,t,o={}){let n=t.render,r=Df(t,o);if(o.enabled===!1)return null;let i=o.state??be;return Vf(e,n,r,i)}function Df(e,t={}){let{className:o,style:n,render:r}=e,{state:i=be,ref:s,props:a,stateAttributesMapping:d,enabled:c=!0}=t,l=c?sa(o,i):void 0,f=c?aa(n,i):void 0,p=c?ia(i,d):be,m=c&&a?jf(a):void 0,u=c?Bo(p,m)??{}:be;return typeof document<"u"&&(c?Array.isArray(s)?u.ref=ea([u.ref,Rr(r),...s]):u.ref=xr(u.ref,Rr(r),s):xr(null,null)),c?(l!==void 0&&(u.className=Tr(u.className,l)),f!==void 0&&(u.style=Bo(u.style,f)),u):be}function jf(e){return Array.isArray(e)?ca(e):ye(void 0,e)}var Ff=Symbol.for("react.lazy");function Vf(e,t,o,n){if(t){if(typeof t=="function")return t(o,n);let r=ye(o,t.props);r.ref=o.ref;let i=t;return i?.$$typeof===Ff&&(i=Wt.Children.toArray(t)[0]),Wt.cloneElement(i,r)}if(e&&typeof e=="string")return Wf(e,o);throw new Error(Pe(8))}function Wf(e,t){return e==="button"?(0,kr.createElement)("button",{type:"button",...t,key:t.key}):e==="img"?(0,kr.createElement)("img",{alt:"",...t,key:t.key}):Wt.createElement(e,t)}var vn=h(z(),1);var pa=0;function Yf(e,t="mui"){let[o,n]=vn.useState(e),r=e||o;return vn.useEffect(()=>{o==null&&(pa+=1,n(`${t}-${pa}`))},[o,t]),r}var ma=Mo.useId;function Lt(e,t){if(ma!==void 0){let o=ma();return e??(t?`${t}-${o}`:o)}return Yf(e,t)}function ga(e){return Lt(e,"base-ui")}var U={};At(U,{cancelOpen:()=>wp,chipRemovePress:()=>ep,clearPress:()=>$f,closePress:()=>Qf,closeWatcher:()=>up,decrementPress:()=>np,disabled:()=>_p,drag:()=>gp,escapeKey:()=>dp,focusOut:()=>lp,imperativeAction:()=>Rp,incrementPress:()=>op,initial:()=>xp,inputBlur:()=>sp,inputChange:()=>rp,inputClear:()=>ip,inputPaste:()=>ap,inputPress:()=>cp,itemPress:()=>Zf,keyboard:()=>pp,linkPress:()=>Jf,listNavigation:()=>fp,missing:()=>yp,none:()=>Uf,outsidePress:()=>qf,pointer:()=>mp,scrub:()=>hp,siblingOpen:()=>vp,swipe:()=>Sp,trackPress:()=>tp,triggerFocus:()=>Kf,triggerHover:()=>Xf,triggerPress:()=>Gf,wheel:()=>bp,windowResize:()=>Ep});var Uf="none",Gf="trigger-press",Xf="trigger-hover",Kf="trigger-focus",qf="outside-press",Zf="item-press",Qf="close-press",Jf="link-press",$f="clear-press",ep="chip-remove-press",tp="track-press",op="increment-press",np="decrement-press",rp="input-change",ip="input-clear",sp="input-blur",ap="input-paste",cp="input-press",lp="focus-out",dp="escape-key",up="close-watcher",fp="list-navigation",pp="keyboard",mp="pointer",gp="drag",bp="wheel",hp="scrub",wp="cancel-open",vp="sibling-open",_p="disabled",yp="missing",xp="initial",Rp="imperative-action",Sp="swipe",Ep="window-resize";function ee(e,t,o,n){let r=!1,i=!1,s=n??be;return{reason:e,event:t??new Event("base-ui"),cancel(){r=!0},allowPropagation(){i=!0},get isCanceled(){return r},get isPropagationAllowed(){return i},trigger:o,...s}}var Cr=h(z(),1);var ba=h(z(),1),Tp=[];function co(e){ba.useEffect(e,Tp)}var _n=null,ah=globalThis.requestAnimationFrame,Pr=class{callbacks=[];callbacksCount=0;nextId=1;startId=1;isScheduled=!1;tick=t=>{this.isScheduled=!1;let o=this.callbacks,n=this.callbacksCount;if(this.callbacks=[],this.callbacksCount=0,this.startId=this.nextId,n>0)for(let r=0;r=this.callbacks.length||(this.callbacks[o]=null,this.callbacksCount-=1)}},yn=new Pr,ft=class e{static create(){return new e}static request(t){return yn.request(t)}static cancel(t){return yn.cancel(t)}currentId=_n;request(t){this.cancel(),this.currentId=yn.request(()=>{this.currentId=_n,t()})}cancel=()=>{this.currentId!==_n&&(yn.cancel(this.currentId),this.currentId=_n)};disposeEffect=()=>this.cancel};function lo(){let e=Se(ft.create).current;return co(e.disposeEffect),e}function ha(e,t=!1,o=!1){let[n,r]=Cr.useState(e&&t?"idle":void 0),[i,s]=Cr.useState(e);return e&&!i&&(s(!0),r("starting")),!e&&i&&n!=="ending"&&!o&&r("ending"),!e&&!i&&n==="ending"&&r(void 0),D(()=>{if(!e&&i&&n!=="ending"&&o){let a=ft.request(()=>{r("ending")});return()=>{ft.cancel(a)}}},[e,i,n,o]),D(()=>{if(!e||t)return;let a=ft.request(()=>{r(void 0)});return()=>{ft.cancel(a)}},[t,e]),D(()=>{if(!e||!t)return;e&&i&&n!=="idle"&&r("starting");let a=ft.request(()=>{r("idle")});return()=>{ft.cancel(a)}},[t,e,i,n]),{mounted:i,setMounted:s,transitionStatus:n}}var Yt=(function(e){return e.startingStyle="data-starting-style",e.endingStyle="data-ending-style",e})({}),kp={[Yt.startingStyle]:""},Pp={[Yt.endingStyle]:""},wa={transitionStatus(e){return e==="starting"?kp:e==="ending"?Pp:null}};var po=h(z(),1);function xn(){return typeof window<"u"}function Gt(e){return Rn(e)?(e.nodeName||"").toLowerCase():"#document"}function ge(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function ot(e){var t;return(t=(Rn(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function Rn(e){return xn()?e instanceof Node||e instanceof ge(e).Node:!1}function V(e){return xn()?e instanceof Element||e instanceof ge(e).Element:!1}function we(e){return xn()?e instanceof HTMLElement||e instanceof ge(e).HTMLElement:!1}function uo(e){return!xn()||typeof ShadowRoot>"u"?!1:e instanceof ShadowRoot||e instanceof ge(e).ShadowRoot}function fo(e){let{overflow:t,overflowX:o,overflowY:n,display:r}=Ae(e);return/auto|scroll|overlay|hidden|clip/.test(t+n+o)&&r!=="inline"&&r!=="contents"}function va(e){return/^(table|td|th)$/.test(Gt(e))}function Do(e){try{if(e.matches(":popover-open"))return!0}catch{}try{return e.matches(":modal")}catch{return!1}}var Cp=/transform|translate|scale|rotate|perspective|filter/,Ap=/paint|layout|strict|content/,Ut=e=>!!e&&e!=="none",Ar;function Sn(e){let t=V(e)?Ae(e):e;return Ut(t.transform)||Ut(t.translate)||Ut(t.scale)||Ut(t.rotate)||Ut(t.perspective)||!En()&&(Ut(t.backdropFilter)||Ut(t.filter))||Cp.test(t.willChange||"")||Ap.test(t.contain||"")}function _a(e){let t=tt(e);for(;we(t)&&!nt(t);){if(Sn(t))return t;if(Do(t))return null;t=tt(t)}return null}function En(){return Ar==null&&(Ar=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),Ar}function nt(e){return/^(html|body|#document)$/.test(Gt(e))}function Ae(e){return ge(e).getComputedStyle(e)}function jo(e){return V(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function tt(e){if(Gt(e)==="html")return e;let t=e.assignedSlot||e.parentNode||uo(e)&&e.host||ot(e);return uo(t)?t.host:t}function ya(e){let t=tt(e);return nt(t)?e.ownerDocument?e.ownerDocument.body:e.body:we(t)&&fo(t)?t:ya(t)}function It(e,t,o){var n;t===void 0&&(t=[]),o===void 0&&(o=!0);let r=ya(e),i=r===((n=e.ownerDocument)==null?void 0:n.body),s=ge(r);if(i){let a=Tn(s);return t.concat(s,s.visualViewport||[],fo(r)?r:[],a&&o?It(a):[])}else return t.concat(r,It(r,[],o))}function Tn(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var kn=h(z(),1),Op=kn.createContext(void 0);function xa(e=!1){let t=kn.useContext(Op);if(t===void 0&&!e)throw new Error(Pe(16));return t}var Ra=h(z(),1);function Sa(e){let{focusableWhenDisabled:t,disabled:o,composite:n=!1,tabIndex:r=0,isNativeButton:i}=e,s=n&&t!==!1,a=n&&t===!1;return{props:Ra.useMemo(()=>{let c={onKeyDown(l){o&&t&&l.key!=="Tab"&&l.preventDefault()}};return n||(c.tabIndex=r,!i&&o&&(c.tabIndex=t?r:-1)),(i&&(t||s)||!i&&o)&&(c["aria-disabled"]=o),i&&(!t||a)&&(c.disabled=o),c},[n,o,t,s,a,i,r])}}function Ea(e={}){let{disabled:t=!1,focusableWhenDisabled:o,tabIndex:n=0,native:r=!0,composite:i}=e,s=po.useRef(null),a=xa(!0),d=i??a!==void 0,{props:c}=Sa({focusableWhenDisabled:o,disabled:t,composite:d,tabIndex:n,isNativeButton:r}),l=po.useCallback(()=>{let m=s.current;Or(m)&&d&&t&&c.disabled===void 0&&m.disabled&&(m.disabled=!1)},[t,c.disabled,d]);D(l,[l]);let f=po.useCallback((m={})=>{let{onClick:u,onMouseDown:g,onKeyUp:v,onKeyDown:_,onPointerDown:w,...y}=m;return ye({onClick(b){if(t){b.preventDefault();return}u?.(b)},onMouseDown(b){t||g?.(b)},onKeyDown(b){if(t||(zo(b),_?.(b),b.baseUIHandlerPrevented))return;let S=b.target===b.currentTarget,x=b.currentTarget,E=Or(x),T=!r&&Np(x),k=S&&(r?E:!T),C=b.key==="Enter",j=b.key===" ",A=x.getAttribute("role"),L=A?.startsWith("menuitem")||A==="option"||A==="gridcell";if(S&&d&&j){if(b.defaultPrevented&&L)return;b.preventDefault(),T||r&&E?(x.click(),b.preventBaseUIHandler()):k&&(u?.(b),b.preventBaseUIHandler());return}k&&(!r&&(j||C)&&b.preventDefault(),!r&&C&&u?.(b))},onKeyUp(b){if(!t){if(zo(b),v?.(b),b.target===b.currentTarget&&r&&d&&Or(b.currentTarget)&&b.key===" "){b.preventDefault();return}b.baseUIHandlerPrevented||b.target===b.currentTarget&&!r&&!d&&b.key===" "&&u?.(b)}},onPointerDown(b){if(t){b.preventDefault();return}w?.(b)}},r?{type:"button"}:{role:"button"},c,y)},[t,c,d,r]),p=Y(m=>{s.current=m,l()});return{getButtonProps:f,buttonRef:p}}function Or(e){return we(e)&&e.tagName==="BUTTON"}function Np(e){return!!(e?.tagName==="A"&&e?.href)}function re(e,t,o,n){return e.addEventListener(t,o,n),()=>{e.removeEventListener(t,o,n)}}function ze(e){let t=Se(Lp,e).current;return t.next=e,D(t.effect),t}function Lp(e){let t={current:e,next:e,effect:()=>{t.current=t.next}};return t}function xe(e){return e?.ownerDocument||document}var Ca=h(z(),1);var Pa=h(Mt(),1);function ka(e){return e==null?e:"current"in e?e.current:e}function mo(e,t=!1,o=!0){let n=lo();return Y((r,i=null)=>{n.cancel();let s=ka(e);if(s==null)return;let a=s,d=()=>{Pa.flushSync(r)};if(typeof a.getAnimations!="function"||globalThis.BASE_UI_ANIMATIONS_DISABLED){r();return}function c(){Promise.all(a.getAnimations().map(l=>l.finished)).then(()=>{i?.aborted||d()}).catch(()=>{if(o){i?.aborted||d();return}let l=a.getAnimations();!i?.aborted&&l.length>0&&l.some(f=>f.pending||f.playState!=="finished")&&c()})}if(t){let l=Yt.startingStyle;if(!a.hasAttribute(l)){n.request(c);return}let f=new MutationObserver(()=>{a.hasAttribute(l)||(f.disconnect(),c())});f.observe(a,{attributes:!0,attributeFilter:[l]}),i?.addEventListener("abort",()=>f.disconnect(),{once:!0});return}n.request(c)})}function Pn(e){let{enabled:t=!0,open:o,ref:n,onComplete:r}=e,i=Y(r),s=mo(n,o,!1);Ca.useEffect(()=>{if(!t)return;let a=new AbortController;return s(i,a.signal),()=>{a.abort()}},[t,o,i,s])}var Aa=h(z(),1);function Oa(e){let t=Aa.useRef(!0);t.current&&(t.current=!1,e())}var xt={};At(xt,{engine:()=>Br,env:()=>zr,os:()=>Ir,screenReader:()=>Hr});var Ir={};At(Ir,{android:()=>Ia,apple:()=>Lr,ios:()=>Nr,linux:()=>zp,mac:()=>Ma,windows:()=>Hp});function Ip(){return typeof navigator>"u"?{userAgent:"",platform:"",maxTouchPoints:0}:{userAgent:navigator.userAgent,platform:navigator.platform??"",maxTouchPoints:navigator.maxTouchPoints??0}}var{userAgent:Mp,platform:Bp,maxTouchPoints:Na}=Ip(),Xt=Mp.toLowerCase(),Kt=Bp.toLowerCase();var Nr=/^i(os$|p)/.test(Kt)||Kt==="macintel"&&Na>1,La="android",Ia=Kt===La||Xt.includes(La),Ma=!Nr&&Kt.startsWith("mac"),Hp=Kt.startsWith("win"),zp=!Ia&&/^(linux|chrome os)/.test(Kt),Lr=Ma||Nr;var Br={};At(Br,{blink:()=>jp,gecko:()=>Dp,webkit:()=>Mr});var Mr=typeof CSS<"u"&&!!CSS.supports?.("-webkit-backdrop-filter:none"),Dp=!Mr&&Xt.includes("firefox"),jp=!Mr&&Xt.includes("chrom");var Hr={};At(Hr,{voiceOver:()=>Fp});var Fp=Lr;var zr={};At(zr,{jsdom:()=>Vp});var Vp=/jsdom|happydom/.test(Xt);var Fo=0,Ye=class e{static create(){return new e}currentId=Fo;start(t,o){this.clear(),this.currentId=setTimeout(()=>{this.currentId=Fo,o()},t)}isStarted(){return this.currentId!==Fo}clear=()=>{this.currentId!==Fo&&(clearTimeout(this.currentId),this.currentId=Fo)};disposeEffect=()=>this.clear};function rt(){let e=Se(Ye.create).current;return co(e.disposeEffect),e}var Oe=h(z(),1);function Ba(e){return"nativeEvent"in e}function Rt(e,t){let o=["mouse","pen"];return t||o.push("",void 0),o.includes(e)}function Ha(e){let t=e.type;return t==="click"||t==="mousedown"||t==="keydown"||t==="keyup"}var Dr="data-base-ui-focusable";var jr="input:not([type='hidden']):not([disabled]),[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";function Cn(e){let t=e.activeElement;for(;t?.shadowRoot?.activeElement!=null;)t=t.shadowRoot.activeElement;return t}function ie(e,t){if(!e||!t)return!1;let o=t.getRootNode?.();if(e.contains(t))return!0;if(o&&uo(o)){let n=t;for(;n;){if(e===n)return!0;n=n.parentNode||n.host}}return!1}function Me(e){return"composedPath"in e?e.composedPath()[0]:e.target}function Bt(e,t){if(!V(e))return!1;let o=e;if(t.hasElement(o))return!o.hasAttribute("data-trigger-disabled");for(let[,n]of t.entries())if(ie(n,o))return!n.hasAttribute("data-trigger-disabled");return!1}function An(e,t){if(t==null)return!1;if("composedPath"in e)return e.composedPath().includes(t);let o=e;return o.target!=null&&t.contains(o.target)}function za(e){return e.matches("html,body")}function Da(e){return we(e)&&e.matches(jr)}function Fr(e){return e?.closest(`button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${jr}`)!=null}function ja(e){if(!e||xt.env.jsdom)return!0;try{return e.matches(":focus-visible")}catch{return!0}}function Wp(e,t){return t!=null&&!Rt(t)?0:typeof e=="function"?e():e}function St(e,t,o){let n=Wp(e,o);return typeof n=="number"?n:n?.[t]}function Vr(e){return typeof e=="function"?e():e}function On(e,t){return t||e==="click"||e==="mousedown"}function Fa(e){return e?.includes("mouse")&&e!=="mousedown"}var Va=h(Q(),1),Wa=Oe.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new Ye,currentIdRef:{current:null},currentContextRef:{current:null}});function Yp(e,t){e.current=t.current}function Wr(e){let{children:t,delay:o,timeoutMs:n=0}=e,r=Oe.useRef(o),i=Oe.useRef(o),s=Oe.useRef(null),a=Oe.useRef(null),d=rt();return D(()=>{if(i.current=o,!s.current){r.current=o;return}r.current={open:St(r.current,"open"),close:St(o,"close")}},[o,s,r,i]),(0,Va.jsx)(Wa.Provider,{value:Oe.useMemo(()=>({hasProvider:!0,delayRef:r,initialDelayRef:i,currentIdRef:s,timeoutMs:n,currentContextRef:a,timeout:d}),[n,d]),children:t})}function Yr(e,t={open:!1}){let{open:o}=t,n="rootStore"in e?e.rootStore:e,r=n.useState("floatingId"),i=Oe.useContext(Wa),{currentIdRef:s,delayRef:a,timeoutMs:d,initialDelayRef:c,currentContextRef:l,hasProvider:f,timeout:p}=i,[m,u]=Oe.useState(!1),g=Oe.useRef(o),v=Oe.useRef(!1);return D(()=>{g.current=o},[o]),D(()=>()=>{v.current=!0},[]),D(()=>{function _(){v.current||u(!1),l.current?.setIsInstantPhase(!1),s.current=null,l.current=null,a.current=c.current,p.clear()}if(s.current&&!o&&s.current===r){if(u(!1),d){let w=r;return p.start(d,()=>{n.select("open")||s.current&&s.current!==w||_()}),()=>{(g.current||s.current!==w)&&p.clear()}}_()}},[o,r,s,a,d,c,l,p,n]),D(()=>{if(!o)return;let _=l.current,w=s.current;p.clear(),l.current={onOpenChange:n.setOpen,setIsInstantPhase:u},s.current=r,a.current={open:0,close:St(c.current,"close")},w!==null&&w!==r?(u(!0),_?.setIsInstantPhase(!0),_?.onOpenChange(!1,ee(U.none))):(u(!1),_?.setIsInstantPhase(!1))},[o,r,n,s,a,c,l,p]),D(()=>()=>{if(s.current===r){if(l.current=null,!g.current)return;s.current=null,Yp(a,c),p.clear()}},[l,s,a,r,c,p]),Oe.useMemo(()=>({hasProvider:f,delayRef:a,isInstantPhase:m}),[f,a,m])}function it(...e){return()=>{for(let t=0;t({x:e,y:e}),Up={left:"right",right:"left",bottom:"top",top:"bottom"};function Yo(e,t,o){return Be(e,Ht(t,o))}function at(e,t){return typeof e=="function"?e(t):e}function Ee(e){return e.split("-")[0]}function ct(e){return e.split("-")[1]}function Ln(e){return e==="x"?"y":"x"}function Uo(e){return e==="y"?"height":"width"}function De(e){let t=e[0];return t==="t"||t==="b"?"y":"x"}function Go(e){return Ln(De(e))}function Xa(e,t,o){o===void 0&&(o=!1);let n=ct(e),r=Go(e),i=Uo(r),s=r==="x"?n===(o?"end":"start")?"right":"left":n==="start"?"bottom":"top";return t.reference[i]>t.floating[i]&&(s=Vo(s)),[s,Vo(s)]}function Ka(e){let t=Vo(e);return[Nn(e),t,Nn(t)]}function Nn(e){return e.includes("start")?e.replace("start","end"):e.replace("end","start")}var Ya=["left","right"],Ua=["right","left"],Gp=["top","bottom"],Xp=["bottom","top"];function Kp(e,t,o){switch(e){case"top":case"bottom":return o?t?Ua:Ya:t?Ya:Ua;case"left":case"right":return t?Gp:Xp;default:return[]}}function qa(e,t,o,n){let r=ct(e),i=Kp(Ee(e),o==="start",n);return r&&(i=i.map(s=>s+"-"+r),t&&(i=i.concat(i.map(Nn)))),i}function Vo(e){let t=Ee(e);return Up[t]+e.slice(t.length)}function qp(e){return{top:0,right:0,bottom:0,left:0,...e}}function In(e){return typeof e!="number"?qp(e):{top:e,right:e,bottom:e,left:e}}function qt(e){let{x:t,y:o,width:n,height:r}=e;return{width:n,height:r,top:o,left:t,right:t+n,bottom:o+r,x:t,y:o}}function Et(e,t,o=!0){return e.filter(r=>r.parentId===t).flatMap(r=>[...!o||r.context?.open?[r]:[],...Et(e,r.id,o)])}function go(e){return`data-base-ui-${e}`}var Ue=h(z(),1),Ja=h(Mt(),1);var Za={style:{transition:"none"}};var Zp="data-base-ui-swipe-ignore",Qp="data-swipe-ignore",ww=`[${Zp}]`,vw=`[${Qp}]`;var Qa={fallbackAxisSide:"end"};var $a=h(Q(),1),Jp=Ue.createContext(null),$p=()=>Ue.useContext(Jp),em=go("portal");function Ur(e={}){let{ref:t,container:o,componentProps:n=be,elementProps:r}=e,i=Lt(),a=$p()?.portalNode,[d,c]=Ue.useState(null),[l,f]=Ue.useState(null),p=Y(v=>{v!==null&&f(v)}),m=Ue.useRef(null);D(()=>{if(o===null){m.current&&(m.current=null,f(null),c(null));return}if(i==null)return;let v=(o&&(Rn(o)?o:o.current))??a??document.body;if(v==null){m.current&&(m.current=null,f(null),c(null));return}m.current!==v&&(m.current=v,f(null),c(v))},[o,a,i]);let u=Ce("div",n,{ref:[t,p],props:[{id:i,[em]:""},r]});return{portalNode:l,portalSubtree:d&&u?Ja.createPortal(u,d):null}}var Zt=h(z(),1);function ec(){let e=new Map;return{emit(t,o){e.get(t)?.forEach(n=>n(o))},on(t,o){e.has(t)||e.set(t,new Set),e.get(t).add(o)},off(t,o){e.get(t)?.delete(o)}}}var tm=h(Q(),1),om=Zt.createContext(null),nm=Zt.createContext(null),bo=()=>Zt.useContext(om)?.id||null,Dt=e=>{let t=Zt.useContext(nm);return e??t};var je=h(z(),1);function rm(e,t){let o=null,n=null,r=!1;return{contextElement:e||void 0,getBoundingClientRect(){let i=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},s=t.axis==="x"||t.axis==="both",a=t.axis==="y"||t.axis==="both",d=["mouseenter","mousemove"].includes(t.dataRef.current.openEvent?.type||"")&&t.pointerType!=="touch",c=i.width,l=i.height,f=i.x,p=i.y;return o==null&&t.x&&s&&(o=i.x-t.x),n==null&&t.y&&a&&(n=i.y-t.y),f-=o||0,p-=n||0,c=0,l=0,!r||d?(c=t.axis==="y"?i.width:0,l=t.axis==="x"?i.height:0,f=s&&t.x!=null?t.x:f,p=a&&t.y!=null?t.y:p):r&&!d&&(l=t.axis==="x"?i.height:l,c=t.axis==="y"?i.width:c),r=!0,{width:c,height:l,x:f,y:p,top:p,right:f+c,bottom:p+l,left:f}}}}function tc(e){return e!=null&&e.clientX!=null}function Gr(e,t={}){let{enabled:o=!0,axis:n="both"}=t,r="rootStore"in e?e.rootStore:e,i=r.useState("open"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.context.dataRef,c=je.useRef(!1),l=je.useRef(null),[f,p]=je.useState(),[m,u]=je.useState([]),g=Y(b=>{r.set("positionReference",b)}),v=Y((b,S,x)=>{c.current||d.current.openEvent&&!tc(d.current.openEvent)||r.set("positionReference",rm(x??a,{x:b,y:S,axis:n,dataRef:d,pointerType:f}))}),_=Y(b=>{i?l.current||(v(b.clientX,b.clientY,b.currentTarget),u([])):v(b.clientX,b.clientY,b.currentTarget)}),w=Rt(f)?s:i;je.useEffect(()=>{if(!o){g(a);return}if(!w)return;function b(){l.current?.(),l.current=null}let S=ge(s);function x(E){let T=Me(E);ie(s,T)?b():v(E.clientX,E.clientY)}return!d.current.openEvent||tc(d.current.openEvent)?l.current=re(S,"mousemove",x):g(a),b},[w,o,s,d,a,r,v,g,m]),je.useEffect(()=>()=>{r.set("positionReference",null)},[r]),je.useEffect(()=>{o&&!s&&(c.current=!1)},[o,s]),je.useEffect(()=>{!o&&i&&(c.current=!0)},[o,i]);let y=je.useMemo(()=>{function b(S){p(S.pointerType)}return{onPointerDown:b,onPointerEnter:b,onMouseMove:_,onMouseEnter:_}},[_]);return je.useMemo(()=>o?{reference:y,trigger:y}:{},[o,y])}var Fe=h(z(),1);function im(){return!1}function sm(e){return{escapeKey:typeof e=="boolean"?e:e?.escapeKey??!1,outsidePress:typeof e=="boolean"?e:e?.outsidePress??!0}}function Xr(e,t={}){let{enabled:o=!0,escapeKey:n=!0,outsidePress:r=!0,outsidePressEvent:i="sloppy",referencePress:s=im,bubbles:a,externalTree:d}=t,c="rootStore"in e?e.rootStore:e,l=c.useState("open"),f=c.useState("floatingElement"),{dataRef:p}=c.context,m=Dt(d),u=Y(typeof r=="function"?r:()=>!1),g=typeof r=="function"?u:r,v=g!==!1,_=Y(()=>i),{escapeKey:w,outsidePress:y}=sm(a),b=Fe.useRef(!1),S=Fe.useRef(!1),x=Fe.useRef(!1),E=Fe.useRef(!1),T=Fe.useRef(""),k=Fe.useRef(null),C=rt(),j=rt(),A=Y(()=>{j.clear(),p.current.insideReactTree=!1}),L=Y(W=>{let oe=p.current.floatingContext?.nodeId;return(m?Et(m.nodesRef.current,oe):[]).some(se=>se.context?.open&&!se.context.dataRef.current[W])}),I=Y(W=>An(W,c.select("floatingElement"))||An(W,c.select("domReferenceElement"))),R=Y(W=>{s()&&c.setOpen(!1,ee(U.triggerPress,W.nativeEvent))}),N=Y(W=>{if(!l||!o||!n||W.key!=="Escape"||E.current||!w&&L("__escapeKeyBubbles"))return;let oe=Ba(W)?W.nativeEvent:W,te=ee(U.escapeKey,oe);c.setOpen(!1,te),te.isCanceled||W.preventDefault(),!w&&!te.isPropagationAllowed&&W.stopPropagation()}),H=Y(()=>{p.current.insideReactTree=!0,j.start(0,A)}),P=Y(W=>{if(!l||!o||W.button!==0)return;let oe=Me(W.nativeEvent);ie(c.select("floatingElement"),oe)&&(b.current||(b.current=!0,S.current=!1))}),O=Y(W=>{!l||!o||(W.defaultPrevented||W.nativeEvent.defaultPrevented)&&b.current&&(S.current=!0)});Fe.useEffect(()=>{if(!l||!o)return;p.current.__escapeKeyBubbles=w,p.current.__outsidePressBubbles=y;let W=new Ye,oe=new Ye;function te(){W.clear(),E.current=!0}function se(){W.start(xt.engine.webkit?5:0,()=>{E.current=!1})}function G(){x.current=!0,oe.start(0,()=>{x.current=!1})}function K(){b.current=!1,S.current=!1}function J(){let B=T.current,F=B==="pen"||!B?"mouse":B,he=_(),ke=typeof he=="function"?he():he;return typeof ke=="string"?ke:ke[F]}function ne(B){let F=J();return F==="intentional"&&B.type!=="click"||F==="sloppy"&&B.type==="click"}function me(B){let F=p.current.floatingContext?.nodeId,he=m&&Et(m.nodesRef.current,F).some(ke=>An(B,ke.context?.elements.floating));return I(B)||he}function le(B){if(ne(B)){B.type!=="click"&&!I(B)&&(oe.clear(),x.current=!1),A();return}if(p.current.insideReactTree){A();return}let F=Me(B),he=`[${go("inert")}]`,ke=V(F)?F.getRootNode():null,kt=Array.from((uo(ke)?ke:xe(c.select("floatingElement"))).querySelectorAll(he)),Lo=c.context.triggerElements;if(F&&(Lo.hasElement(F)||Lo.hasMatchingElement(We=>ie(We,F))))return;let _t=V(F)?F:null;for(;_t&&!nt(_t);){let We=tt(_t);if(nt(We)||!V(We))break;_t=We}if(!(kt.length&&V(F)&&!za(F)&&!ie(F,c.select("floatingElement"))&&kt.every(We=>!ie(_t,We)))){if(we(F)&&!("touches"in B)){let We=nt(F),Pt=Ae(F),Ct=/auto|scroll/,un=We||Ct.test(Pt.overflowX),fn=We||Ct.test(Pt.overflowY),pn=un&&F.clientWidth>0&&F.scrollWidth>F.clientWidth,mn=fn&&F.clientHeight>0&&F.scrollHeight>F.clientHeight,gn=Pt.direction==="rtl",ae=mn&&(gn?B.offsetX<=F.offsetWidth-F.clientWidth:B.offsetX>F.clientWidth),Ie=pn&&B.offsetY>F.clientHeight;if(ae||Ie)return}if(!me(B)){if(J()==="intentional"&&x.current){oe.clear(),x.current=!1;return}typeof g=="function"&&!g(B)||L("__outsidePressBubbles")||(c.setOpen(!1,ee(U.outsidePress,B)),A())}}}function X(B){J()!=="sloppy"||B.pointerType==="touch"||!c.select("open")||!o||I(B)||le(B)}function pe(B){if(J()!=="sloppy"||!c.select("open")||!o||I(B))return;let F=B.touches[0];F&&(k.current={startTime:Date.now(),startX:F.clientX,startY:F.clientY,dismissOnTouchEnd:!1,dismissOnMouseDown:!0},C.start(1e3,()=>{k.current&&(k.current.dismissOnTouchEnd=!1,k.current.dismissOnMouseDown=!1)}))}function ue(B,F){let he=Me(B);if(!he)return;let ke=re(he,B.type,()=>{F(B),ke()})}function vt(B){T.current="touch",ue(B,pe)}function Te(B){C.clear(),B.type==="pointerdown"&&(T.current=B.pointerType),!(B.type==="mousedown"&&k.current&&!k.current.dismissOnMouseDown)&&ue(B,F=>{F.type==="pointerdown"?X(F):le(F)})}function Ve(B){if(!b.current)return;let F=S.current;if(K(),J()==="intentional"){if(B.type==="pointercancel"){F&&G();return}if(!me(B)){if(F){G();return}typeof g=="function"&&!g(B)||(oe.clear(),x.current=!0,A())}}}function Ke(B){if(J()!=="sloppy"||!k.current||I(B))return;let F=B.touches[0];if(!F)return;let he=Math.abs(F.clientX-k.current.startX),ke=Math.abs(F.clientY-k.current.startY),kt=Math.sqrt(he*he+ke*ke);kt>5&&(k.current.dismissOnTouchEnd=!0),kt>10&&(le(B),C.clear(),k.current=null)}function He(B){ue(B,Ke)}function no(B){J()!=="sloppy"||!k.current||I(B)||(k.current.dismissOnTouchEnd&&le(B),C.clear(),k.current=null)}function dn(B){ue(B,no)}let _e=xe(f),ro=it(n&&it(re(_e,"keydown",N),re(_e,"compositionstart",te),re(_e,"compositionend",se)),v&&it(re(_e,"click",Te,!0),re(_e,"pointerdown",Te,!0),re(_e,"pointerup",Ve,!0),re(_e,"pointercancel",Ve,!0),re(_e,"mousedown",Te,!0),re(_e,"mouseup",Ve,!0),re(_e,"touchstart",vt,!0),re(_e,"touchmove",He,!0),re(_e,"touchend",dn,!0)));return()=>{ro(),W.clear(),oe.clear(),K(),x.current=!1}},[p,f,n,v,g,l,o,w,y,N,A,_,L,I,m,c,C]),Fe.useEffect(A,[g,A]);let M=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:R,onClick:R}),[N,R]),Z=Fe.useMemo(()=>({onKeyDown:N,onPointerDown:O,onMouseDown:O,onClickCapture:H,onMouseDownCapture(W){H(),P(W)},onPointerDownCapture(W){H(),P(W)},onMouseUpCapture:H,onTouchEndCapture:H,onTouchMoveCapture:H}),[N,H,P,O]);return Fe.useMemo(()=>o?{reference:M,floating:Z,trigger:M}:{},[o,M,Z])}var Ne=h(z(),1);function oc(e,t,o){let{reference:n,floating:r}=e,i=De(t),s=Go(t),a=Uo(s),d=Ee(t),c=i==="y",l=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2,p=n[a]/2-r[a]/2,m;switch(d){case"top":m={x:l,y:n.y-r.height};break;case"bottom":m={x:l,y:n.y+n.height};break;case"right":m={x:n.x+n.width,y:f};break;case"left":m={x:n.x-r.width,y:f};break;default:m={x:n.x,y:n.y}}switch(ct(t)){case"start":m[s]-=p*(o&&c?-1:1);break;case"end":m[s]+=p*(o&&c?-1:1);break}return m}async function ic(e,t){var o;t===void 0&&(t={});let{x:n,y:r,platform:i,rects:s,elements:a,strategy:d}=e,{boundary:c="clippingAncestors",rootBoundary:l="viewport",elementContext:f="floating",altBoundary:p=!1,padding:m=0}=at(t,e),u=In(m),v=a[p?f==="floating"?"reference":"floating":f],_=qt(await i.getClippingRect({element:(o=await(i.isElement==null?void 0:i.isElement(v)))==null||o?v:v.contextElement||await(i.getDocumentElement==null?void 0:i.getDocumentElement(a.floating)),boundary:c,rootBoundary:l,strategy:d})),w=f==="floating"?{x:n,y:r,width:s.floating.width,height:s.floating.height}:s.reference,y=await(i.getOffsetParent==null?void 0:i.getOffsetParent(a.floating)),b=await(i.isElement==null?void 0:i.isElement(y))?await(i.getScale==null?void 0:i.getScale(y))||{x:1,y:1}:{x:1,y:1},S=qt(i.convertOffsetParentRelativeRectToViewportRelativeRect?await i.convertOffsetParentRelativeRectToViewportRelativeRect({elements:a,rect:w,offsetParent:y,strategy:d}):w);return{top:(_.top-S.top+u.top)/b.y,bottom:(S.bottom-_.bottom+u.bottom)/b.y,left:(_.left-S.left+u.left)/b.x,right:(S.right-_.right+u.right)/b.x}}var am=50,sc=async(e,t,o)=>{let{placement:n="bottom",strategy:r="absolute",middleware:i=[],platform:s}=o,a=s.detectOverflow?s:{...s,detectOverflow:ic},d=await(s.isRTL==null?void 0:s.isRTL(t)),c=await s.getElementRects({reference:e,floating:t,strategy:r}),{x:l,y:f}=oc(c,n,d),p=n,m=0,u={};for(let g=0;gI<=0)){var j,A;let I=(((j=i.flip)==null?void 0:j.index)||0)+1,R=E[I];if(R&&(!(f==="alignment"?w!==De(R):!1)||C.every(P=>De(P.placement)===w?P.overflows[0]>0:!0)))return{data:{index:I,overflows:C},reset:{placement:R}};let N=(A=C.filter(H=>H.overflows[0]<=0).sort((H,P)=>H.overflows[1]-P.overflows[1])[0])==null?void 0:A.placement;if(!N)switch(m){case"bestFit":{var L;let H=(L=C.filter(P=>{if(x){let O=De(P.placement);return O===w||O==="y"}return!0}).map(P=>[P.placement,P.overflows.filter(O=>O>0).reduce((O,M)=>O+M,0)]).sort((P,O)=>P[1]-O[1])[0])==null?void 0:L[0];H&&(N=H);break}case"initialPlacement":N=a;break}if(r!==N)return{reset:{placement:N}}}return{}}}};function nc(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function rc(e){return Ga.some(t=>e[t]>=0)}var cc=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){let{rects:o,platform:n}=t,{strategy:r="referenceHidden",...i}=at(e,t);switch(r){case"referenceHidden":{let s=await n.detectOverflow(t,{...i,elementContext:"reference"}),a=nc(s,o.reference);return{data:{referenceHiddenOffsets:a,referenceHidden:rc(a)}}}case"escaped":{let s=await n.detectOverflow(t,{...i,altBoundary:!0}),a=nc(s,o.floating);return{data:{escapedOffsets:a,escaped:rc(a)}}}default:return{}}}}};var lc=new Set(["left","top"]);async function cm(e,t){let{placement:o,platform:n,elements:r}=e,i=await(n.isRTL==null?void 0:n.isRTL(r.floating)),s=Ee(o),a=ct(o),d=De(o)==="y",c=lc.has(s)?-1:1,l=i&&d?-1:1,f=at(t,e),{mainAxis:p,crossAxis:m,alignmentAxis:u}=typeof f=="number"?{mainAxis:f,crossAxis:0,alignmentAxis:null}:{mainAxis:f.mainAxis||0,crossAxis:f.crossAxis||0,alignmentAxis:f.alignmentAxis};return a&&typeof u=="number"&&(m=a==="end"?u*-1:u),d?{x:m*l,y:p*c}:{x:p*c,y:m*l}}var dc=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var o,n;let{x:r,y:i,placement:s,middlewareData:a}=t,d=await cm(t,e);return s===((o=a.offset)==null?void 0:o.placement)&&(n=a.arrow)!=null&&n.alignmentOffset?{}:{x:r+d.x,y:i+d.y,data:{...d,placement:s}}}}},uc=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){let{x:o,y:n,placement:r,platform:i}=t,{mainAxis:s=!0,crossAxis:a=!1,limiter:d={fn:_=>{let{x:w,y}=_;return{x:w,y}}},...c}=at(e,t),l={x:o,y:n},f=await i.detectOverflow(t,c),p=De(Ee(r)),m=Ln(p),u=l[m],g=l[p];if(s){let _=m==="y"?"top":"left",w=m==="y"?"bottom":"right",y=u+f[_],b=u-f[w];u=Yo(y,u,b)}if(a){let _=p==="y"?"top":"left",w=p==="y"?"bottom":"right",y=g+f[_],b=g-f[w];g=Yo(y,g,b)}let v=d.fn({...t,[m]:u,[p]:g});return{...v,data:{x:v.x-o,y:v.y-n,enabled:{[m]:s,[p]:a}}}}}},fc=function(e){return e===void 0&&(e={}),{options:e,fn(t){let{x:o,y:n,placement:r,rects:i,middlewareData:s}=t,{offset:a=0,mainAxis:d=!0,crossAxis:c=!0}=at(e,t),l={x:o,y:n},f=De(r),p=Ln(f),m=l[p],u=l[f],g=at(a,t),v=typeof g=="number"?{mainAxis:g,crossAxis:0}:{mainAxis:0,crossAxis:0,...g};if(d){let y=p==="y"?"height":"width",b=i.reference[p]-i.floating[y]+v.mainAxis,S=i.reference[p]+i.reference[y]-v.mainAxis;mS&&(m=S)}if(c){var _,w;let y=p==="y"?"width":"height",b=lc.has(Ee(r)),S=i.reference[f]-i.floating[y]+(b&&((_=s.offset)==null?void 0:_[f])||0)+(b?0:v.crossAxis),x=i.reference[f]+i.reference[y]+(b?0:((w=s.offset)==null?void 0:w[f])||0)-(b?v.crossAxis:0);ux&&(u=x)}return{[p]:m,[f]:u}}}},pc=function(e){return e===void 0&&(e={}),{name:"size",options:e,async fn(t){var o,n;let{placement:r,rects:i,platform:s,elements:a}=t,{apply:d=()=>{},...c}=at(e,t),l=await s.detectOverflow(t,c),f=Ee(r),p=ct(r),m=De(r)==="y",{width:u,height:g}=i.floating,v,_;f==="top"||f==="bottom"?(v=f,_=p===(await(s.isRTL==null?void 0:s.isRTL(a.floating))?"start":"end")?"left":"right"):(_=f,v=p==="end"?"top":"bottom");let w=g-l.top-l.bottom,y=u-l.left-l.right,b=Ht(g-l[v],w),S=Ht(u-l[_],y),x=!t.middlewareData.shift,E=b,T=S;if((o=t.middlewareData.shift)!=null&&o.enabled.x&&(T=y),(n=t.middlewareData.shift)!=null&&n.enabled.y&&(E=w),x&&!p){let C=Be(l.left,0),j=Be(l.right,0),A=Be(l.top,0),L=Be(l.bottom,0);m?T=u-2*(C!==0||j!==0?C+j:Be(l.left,l.right)):E=g-2*(A!==0||L!==0?A+L:Be(l.top,l.bottom))}await d({...t,availableWidth:T,availableHeight:E});let k=await s.getDimensions(a.floating);return u!==k.width||g!==k.height?{reset:{rects:!0}}:{}}}};function hc(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n,a=zt(o)!==i||zt(n)!==s;return a&&(o=i,n=s),{width:o,height:n,$:a}}function qr(e){return V(e)?e:e.contextElement}function ho(e){let t=qr(e);if(!we(t))return st(1);let o=t.getBoundingClientRect(),{width:n,height:r,$:i}=hc(t),s=(i?zt(o.width):o.width)/n,a=(i?zt(o.height):o.height)/r;return(!s||!Number.isFinite(s))&&(s=1),(!a||!Number.isFinite(a))&&(a=1),{x:s,y:a}}var lm=st(0);function wc(e){let t=ge(e);return!En()||!t.visualViewport?lm:{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}}function dm(e,t,o){return t===void 0&&(t=!1),!o||t&&o!==ge(e)?!1:t}function Qt(e,t,o,n){t===void 0&&(t=!1),o===void 0&&(o=!1);let r=e.getBoundingClientRect(),i=qr(e),s=st(1);t&&(n?V(n)&&(s=ho(n)):s=ho(e));let a=dm(i,o,n)?wc(i):st(0),d=(r.left+a.x)/s.x,c=(r.top+a.y)/s.y,l=r.width/s.x,f=r.height/s.y;if(i){let p=ge(i),m=n&&V(n)?ge(n):n,u=p,g=Tn(u);for(;g&&n&&m!==u;){let v=ho(g),_=g.getBoundingClientRect(),w=Ae(g),y=_.left+(g.clientLeft+parseFloat(w.paddingLeft))*v.x,b=_.top+(g.clientTop+parseFloat(w.paddingTop))*v.y;d*=v.x,c*=v.y,l*=v.x,f*=v.y,d+=y,c+=b,u=ge(g),g=Tn(u)}}return qt({width:l,height:f,x:d,y:c})}function Mn(e,t){let o=jo(e).scrollLeft;return t?t.left+o:Qt(ot(e)).left+o}function vc(e,t){let o=e.getBoundingClientRect(),n=o.left+t.scrollLeft-Mn(e,o),r=o.top+t.scrollTop;return{x:n,y:r}}function um(e){let{elements:t,rect:o,offsetParent:n,strategy:r}=e,i=r==="fixed",s=ot(n),a=t?Do(t.floating):!1;if(n===s||a&&i)return o;let d={scrollLeft:0,scrollTop:0},c=st(1),l=st(0),f=we(n);if((f||!f&&!i)&&((Gt(n)!=="body"||fo(s))&&(d=jo(n)),f)){let m=Qt(n);c=ho(n),l.x=m.x+n.clientLeft,l.y=m.y+n.clientTop}let p=s&&!f&&!i?vc(s,d):st(0);return{width:o.width*c.x,height:o.height*c.y,x:o.x*c.x-d.scrollLeft*c.x+l.x+p.x,y:o.y*c.y-d.scrollTop*c.y+l.y+p.y}}function fm(e){return Array.from(e.getClientRects())}function pm(e){let t=ot(e),o=jo(e),n=e.ownerDocument.body,r=Be(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),i=Be(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-o.scrollLeft+Mn(e),a=-o.scrollTop;return Ae(n).direction==="rtl"&&(s+=Be(t.clientWidth,n.clientWidth)-r),{width:r,height:i,x:s,y:a}}var mc=25;function mm(e,t){let o=ge(e),n=ot(e),r=o.visualViewport,i=n.clientWidth,s=n.clientHeight,a=0,d=0;if(r){i=r.width,s=r.height;let l=En();(!l||l&&t==="fixed")&&(a=r.offsetLeft,d=r.offsetTop)}let c=Mn(n);if(c<=0){let l=n.ownerDocument,f=l.body,p=getComputedStyle(f),m=l.compatMode==="CSS1Compat"&&parseFloat(p.marginLeft)+parseFloat(p.marginRight)||0,u=Math.abs(n.clientWidth-f.clientWidth-m);u<=mc&&(i-=u)}else c<=mc&&(i+=c);return{width:i,height:s,x:a,y:d}}function gm(e,t){let o=Qt(e,!0,t==="fixed"),n=o.top+e.clientTop,r=o.left+e.clientLeft,i=we(e)?ho(e):st(1),s=e.clientWidth*i.x,a=e.clientHeight*i.y,d=r*i.x,c=n*i.y;return{width:s,height:a,x:d,y:c}}function gc(e,t,o){let n;if(t==="viewport")n=mm(e,o);else if(t==="document")n=pm(ot(e));else if(V(t))n=gm(t,o);else{let r=wc(e);n={x:t.x-r.x,y:t.y-r.y,width:t.width,height:t.height}}return qt(n)}function _c(e,t){let o=tt(e);return o===t||!V(o)||nt(o)?!1:Ae(o).position==="fixed"||_c(o,t)}function bm(e,t){let o=t.get(e);if(o)return o;let n=It(e,[],!1).filter(a=>V(a)&&Gt(a)!=="body"),r=null,i=Ae(e).position==="fixed",s=i?tt(e):e;for(;V(s)&&!nt(s);){let a=Ae(s),d=Sn(s);!d&&a.position==="fixed"&&(r=null),(i?!d&&!r:!d&&a.position==="static"&&!!r&&(r.position==="absolute"||r.position==="fixed")||fo(s)&&!d&&_c(e,s))?n=n.filter(l=>l!==s):r=a,s=tt(s)}return t.set(e,n),n}function hm(e){let{element:t,boundary:o,rootBoundary:n,strategy:r}=e,s=[...o==="clippingAncestors"?Do(t)?[]:bm(t,this._c):[].concat(o),n],a=gc(t,s[0],r),d=a.top,c=a.right,l=a.bottom,f=a.left;for(let p=1;p{s(!1,1e-7)},1e3)}E===1&&!xc(c,e.getBoundingClientRect())&&s(),b=!1}try{o=new IntersectionObserver(S,{...y,root:r.ownerDocument})}catch{o=new IntersectionObserver(S,y)}o.observe(e)}return s(!0),i}function Xo(e,t,o,n){n===void 0&&(n={});let{ancestorScroll:r=!0,ancestorResize:i=!0,elementResize:s=typeof ResizeObserver=="function",layoutShift:a=typeof IntersectionObserver=="function",animationFrame:d=!1}=n,c=qr(e),l=r||i?[...c?It(c):[],...t?It(t):[]]:[];l.forEach(_=>{r&&_.addEventListener("scroll",o,{passive:!0}),i&&_.addEventListener("resize",o)});let f=c&&a?xm(c,o):null,p=-1,m=null;s&&(m=new ResizeObserver(_=>{let[w]=_;w&&w.target===c&&m&&t&&(m.unobserve(t),cancelAnimationFrame(p),p=requestAnimationFrame(()=>{var y;(y=m)==null||y.observe(t)})),o()}),c&&!d&&m.observe(c),t&&m.observe(t));let u,g=d?Qt(e):null;d&&v();function v(){let _=Qt(e);g&&!xc(g,_)&&o(),g=_,u=requestAnimationFrame(v)}return o(),()=>{var _;l.forEach(w=>{r&&w.removeEventListener("scroll",o),i&&w.removeEventListener("resize",o)}),f?.(),(_=m)==null||_.disconnect(),m=null,d&&cancelAnimationFrame(u)}}var Rc=dc;var Sc=uc,Ec=ac,Tc=pc,kc=cc;var Pc=fc,Bn=(e,t,o)=>{let n=new Map,r={platform:Zr,...o},i={...r.platform,_c:n};return sc(e,t,{...r,platform:i})};var ve=h(z(),1),Ac=h(z(),1),Oc=h(Mt(),1),Sm=typeof document<"u",Em=function(){},Hn=Sm?Ac.useLayoutEffect:Em;function zn(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(typeof e=="function"&&e.toString()===t.toString())return!0;let o,n,r;if(e&&t&&typeof e=="object"){if(Array.isArray(e)){if(o=e.length,o!==t.length)return!1;for(n=o;n--!==0;)if(!zn(e[n],t[n]))return!1;return!0}if(r=Object.keys(e),o=r.length,o!==Object.keys(t).length)return!1;for(n=o;n--!==0;)if(!{}.hasOwnProperty.call(t,r[n]))return!1;for(n=o;n--!==0;){let i=r[n];if(!(i==="_owner"&&e.$$typeof)&&!zn(e[i],t[i]))return!1}return!0}return e!==e&&t!==t}function Nc(e){return typeof window>"u"?1:(e.ownerDocument.defaultView||window).devicePixelRatio||1}function Cc(e,t){let o=Nc(e);return Math.round(t*o)/o}function Qr(e){let t=ve.useRef(e);return Hn(()=>{t.current=e}),t}function Lc(e){e===void 0&&(e={});let{placement:t="bottom",strategy:o="absolute",middleware:n=[],platform:r,elements:{reference:i,floating:s}={},transform:a=!0,whileElementsMounted:d,open:c}=e,[l,f]=ve.useState({x:0,y:0,strategy:o,placement:t,middlewareData:{},isPositioned:!1}),[p,m]=ve.useState(n);zn(p,n)||m(n);let[u,g]=ve.useState(null),[v,_]=ve.useState(null),w=ve.useCallback(P=>{P!==x.current&&(x.current=P,g(P))},[]),y=ve.useCallback(P=>{P!==E.current&&(E.current=P,_(P))},[]),b=i||u,S=s||v,x=ve.useRef(null),E=ve.useRef(null),T=ve.useRef(l),k=d!=null,C=Qr(d),j=Qr(r),A=Qr(c),L=ve.useCallback(()=>{if(!x.current||!E.current)return;let P={placement:t,strategy:o,middleware:p};j.current&&(P.platform=j.current),Bn(x.current,E.current,P).then(O=>{let M={...O,isPositioned:A.current!==!1};I.current&&!zn(T.current,M)&&(T.current=M,Oc.flushSync(()=>{f(M)}))})},[p,t,o,j,A]);Hn(()=>{c===!1&&T.current.isPositioned&&(T.current.isPositioned=!1,f(P=>({...P,isPositioned:!1})))},[c]);let I=ve.useRef(!1);Hn(()=>(I.current=!0,()=>{I.current=!1}),[]),Hn(()=>{if(b&&(x.current=b),S&&(E.current=S),b&&S){if(C.current)return C.current(b,S,L);L()}},[b,S,L,C,k]);let R=ve.useMemo(()=>({reference:x,floating:E,setReference:w,setFloating:y}),[w,y]),N=ve.useMemo(()=>({reference:b,floating:S}),[b,S]),H=ve.useMemo(()=>{let P={position:o,left:0,top:0};if(!N.floating)return P;let O=Cc(N.floating,l.x),M=Cc(N.floating,l.y);return a?{...P,transform:"translate("+O+"px, "+M+"px)",...Nc(N.floating)>=1.5&&{willChange:"transform"}}:{position:o,left:O,top:M}},[o,a,N.floating,l.x,l.y]);return ve.useMemo(()=>({...l,update:L,refs:R,elements:N,floatingStyles:H}),[l,L,R,N,H])}var Jr=(e,t)=>{let o=Rc(e);return{name:o.name,fn:o.fn,options:[e,t]}},$r=(e,t)=>{let o=Sc(e);return{name:o.name,fn:o.fn,options:[e,t]}},ei=(e,t)=>({fn:Pc(e).fn,options:[e,t]}),ti=(e,t)=>{let o=Ec(e);return{name:o.name,fn:o.fn,options:[e,t]}},oi=(e,t)=>{let o=Tc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var ni=(e,t)=>{let o=kc(e);return{name:o.name,fn:o.fn,options:[e,t]}};var _o=h(z(),1),qc=h(Mt(),1);var Xc=h(z(),1);var q=(e,t,o,n,r,i,...s)=>{if(s.length>0)throw new Error(Pe(1));let a;if(e&&t&&o&&n&&r&&i)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f),v=r(d,c,l,f);return i(p,m,u,g,v,c,l,f)};else if(e&&t&&o&&n&&r)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f),g=n(d,c,l,f);return r(p,m,u,g,c,l,f)};else if(e&&t&&o&&n)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f),u=o(d,c,l,f);return n(p,m,u,c,l,f)};else if(e&&t&&o)a=(d,c,l,f)=>{let p=e(d,c,l,f),m=t(d,c,l,f);return o(p,m,c,l,f)};else if(e&&t)a=(d,c,l,f)=>{let p=e(d,c,l,f);return t(p,c,l,f)};else if(e)a=e;else throw new Error("Missing arguments");return a};var Uc=h(z(),1),li=h(ii(),1),Gc=h(jc(),1);var Fc=h(z(),1);var si=[],ai;function Vc(){return ai}function Wc(e){si.push(e)}function ci(e){let t=(o,n)=>{let r=Se(Wm).current,i;try{ai=r;for(let s of si)s.before(r);i=e(o,n);for(let s of si)s.after(r);r.didInitialize=!0}finally{ai=void 0}return i};return t.displayName=e.displayName||e.name,t}function Yc(e){return Fc.forwardRef(ci(e))}function Wm(){return{didInitialize:!1}}var Ym=ao(19),Um=Ym?Xm:Km;function jn(e,t,o,n,r){return Um(e,t,o,n,r)}function Gm(e,t,o,n,r){let i=Uc.useCallback(()=>t(e.getSnapshot(),o,n,r),[e,t,o,n,r]);return(0,li.useSyncExternalStore)(e.subscribe,i,i)}Wc({before(e){e.syncIndex=0,e.didInitialize||(e.syncTick=1,e.syncHooks=[],e.didChangeStore=!0,e.getSnapshot=()=>{let t=!1;for(let o=0;o0&&(e.didChangeStore&&(e.didChangeStore=!1,e.subscribe=t=>{let o=new Set;for(let r of e.syncHooks)o.add(r.store);let n=[];for(let r of o)n.push(r.subscribe(t));return()=>{for(let r of n)r()}}),(0,li.useSyncExternalStore)(e.subscribe,e.getSnapshot,e.getSnapshot))}});function Xm(e,t,o,n,r){let i=Vc();if(!i)return Gm(e,t,o,n,r);let s=i.syncIndex;i.syncIndex+=1;let a;return i.didInitialize?(a=i.syncHooks[s],(a.store!==e||a.selector!==t||!Object.is(a.a1,o)||!Object.is(a.a2,n)||!Object.is(a.a3,r))&&(a.store!==e&&(i.didChangeStore=!0),a.store=e,a.selector=t,a.a1=o,a.a2=n,a.a3=r,a.value=t(e.getSnapshot(),o,n,r))):(a={store:e,selector:t,a1:o,a2:n,a3:r,value:t(e.getSnapshot(),o,n,r)},i.syncHooks.push(a)),a.value}function Km(e,t,o,n,r){return(0,Gc.useSyncExternalStoreWithSelector)(e.subscribe,e.getSnapshot,e.getSnapshot,i=>t(i,o,n,r))}var Fn=class{constructor(t){this.state=t,this.listeners=new Set,this.updateTick=0}subscribe=t=>(this.listeners.add(t),()=>{this.listeners.delete(t)});getSnapshot=()=>this.state;setState(t){if(this.state===t)return;this.state=t,this.updateTick+=1;let o=this.updateTick;for(let n of this.listeners){if(o!==this.updateTick)return;n(t)}}update(t){for(let o in t)if(!Object.is(this.state[o],t[o])){this.setState({...this.state,...t});return}}set(t,o){Object.is(this.state[t],o)||this.setState({...this.state,[t]:o})}notifyAll(){let t={...this.state};this.setState(t)}use(t,o,n,r){return jn(this,t,o,n,r)}};var Jt=h(z(),1);var vo=class extends Fn{constructor(t,o={},n){super(t),this.context=o,this.selectors=n}useSyncedValue(t,o){Jt.useDebugValue(t);let n=this;D(()=>{n.state[t]!==o&&n.set(t,o)},[n,t,o])}useSyncedValueWithCleanup(t,o){let n=this;D(()=>(n.state[t]!==o&&n.set(t,o),()=>{n.set(t,void 0)}),[n,t,o])}useSyncedValues(t){let o=this,n=Object.values(t);D(()=>{o.update(t)},[o,...n])}useControlledProp(t,o){Jt.useDebugValue(t);let n=this,r=o!==void 0;D(()=>{r&&!Object.is(n.state[t],o)&&n.setState({...n.state,[t]:o})},[n,t,o,r])}select(t,o,n,r){let i=this.selectors[t];return i(this.state,o,n,r)}useState(t,o,n,r){return Jt.useDebugValue(t),jn(this,this.selectors[t],o,n,r)}useContextCallback(t,o){Jt.useDebugValue(t);let n=Y(o??Nt);this.context[t]=n}useStateSetter(t){let o=Jt.useRef(void 0);return o.current===void 0&&(o.current=n=>{this.set(t,n)}),o.current}observe(t,o){let n;typeof t=="function"?n=t:n=this.selectors[t];let r=n(this.state);return o(r,r,this),this.subscribe(i=>{let s=n(i);if(!Object.is(r,s)){let a=r;r=s,o(s,a,this)}})}};var qm={open:q(e=>e.open),transitionStatus:q(e=>e.transitionStatus),domReferenceElement:q(e=>e.domReferenceElement),referenceElement:q(e=>e.positionReference??e.referenceElement),floatingElement:q(e=>e.floatingElement),floatingId:q(e=>e.floatingId)},pt=class extends vo{constructor(t){let{syncOnly:o,nested:n,onOpenChange:r,triggerElements:i,...s}=t;super({...s,positionReference:s.referenceElement,domReferenceElement:s.referenceElement},{onOpenChange:r,dataRef:{current:{}},events:ec(),nested:n,triggerElements:i},qm),this.syncOnly=o}syncOpenEvent=(t,o)=>{(!t||!this.state.open||o!=null&&Ha(o))&&(this.context.dataRef.current.openEvent=t?o:void 0)};dispatchOpenChange=(t,o)=>{this.syncOpenEvent(t,o.event);let n={open:t,reason:o.reason,nativeEvent:o.event,nested:this.context.nested,triggerElement:o.trigger};this.context.events.emit("openchange",n)};setOpen=(t,o)=>{if(this.syncOnly){this.context.onOpenChange?.(t,o);return}this.dispatchOpenChange(t,o),this.context.onOpenChange?.(t,o)}};function Kc(e){let{popupStore:t,treatPopupAsFloatingElement:o=!1,floatingRootContext:n,floatingId:r,nested:i,onOpenChange:s}=e,a=t.useState("open"),d=t.useState("activeTriggerElement"),c=t.useState(o?"popupElement":"positionerElement"),l=t.context.triggerElements,f=s,p=Xc.useRef(null);n===void 0&&p.current===null&&(p.current=new pt({open:a,transitionStatus:void 0,referenceElement:d,floatingElement:c,triggerElements:l,onOpenChange:f,floatingId:r,syncOnly:!0,nested:i}));let m=n??p.current;return t.useSyncedValue("floatingId",r),D(()=>{let u={open:a,floatingId:r,referenceElement:d,floatingElement:c};V(d)&&(u.domReferenceElement=d),m.state.positionReference===m.state.referenceElement&&(u.positionReference=d),m.update(u)},[a,r,d,c,m]),m.context.onOpenChange=f,m.context.nested=i,m}var Zc={tabIndex:-1,[Dr]:""};function Qc(e,t,o=!1){let n=Lt(),r=bo()!=null,i=_o.useRef(null);e===void 0&&i.current===null&&(i.current=t(n,r));let s=e??i.current;return Kc({popupStore:s,treatPopupAsFloatingElement:o,floatingRootContext:s.state.floatingRootContext,floatingId:n,nested:r,onOpenChange:s.setOpen}),{store:s,internalStore:i.current}}function Zm(e,t){let o=_o.useRef(null),n=_o.useRef(null);return _o.useCallback(r=>{if(e===void 0)return;let i=!1;if(o.current!==null){let s=o.current,a=n.current,d=t.context.triggerElements.getById(s);a&&d===a&&(t.context.triggerElements.delete(s),i=!0),o.current=null,n.current=null}if(r!==null&&(o.current=e,n.current=r,t.context.triggerElements.add(e,r),i=!0),i){let s=t.context.triggerElements.size;t.select("open")&&t.state.triggerCount!==s&&t.set("triggerCount",s)}},[t,e])}function Qm(e,t,o,n=!1){t?e.preventUnmountingOnClose=!1:n&&(e.preventUnmountingOnClose=!0);let r=o?.id??null;(r||t)&&(e.activeTriggerId=r,e.activeTriggerElement=o??null)}function Jm(e){let t=!1;return e.preventUnmountOnClose=()=>{t=!0},()=>t}function Jc(e,t,o,n={}){let r=o.reason,i=r===U.triggerHover,s=t&&r===U.triggerFocus,a=!t&&(r===U.triggerPress||r===U.escapeKey),d=Jm(o);if(e.context.onOpenChange?.(t,o),o.isCanceled)return;n.onBeforeDispatch?.(),e.state.floatingRootContext.dispatchOpenChange(t,o);let c=()=>{let l={...n.extraState,open:t};s?l.instantType="focus":a?l.instantType="dismiss":i&&(l.instantType=void 0),Qm(l,t,o.trigger,d()),e.update(l)};i?qc.flushSync(c):c()}function $c(e,t,o,n){Oa(()=>{t===void 0&&e.state.open===!1&&o&&(e.state={...e.state,open:!0,activeTriggerId:n,preventUnmountingOnClose:!1})})}function el(e,t,o,n){let r=o.useState("isMountedByTrigger",e),i=Zm(e,o),s=Y(a=>{if(i(a),!a)return;let d=o.select("open"),c=o.select("activeTriggerId");if(c===e){o.update({activeTriggerElement:a,...d?n:null});return}c==null&&d&&o.update({activeTriggerId:e,activeTriggerElement:a,...n})});return D(()=>{r&&o.update({activeTriggerElement:t.current,...n})},[r,o,t,...Object.values(n)]),{registerTrigger:s,isMountedByThisTrigger:r}}function tl(e,t={}){let{closeOnActiveTriggerUnmount:o=!1}=t,n=e.useState("open"),r=e.useState("triggerCount");D(()=>{if(!n){e.state.triggerCount!==0&&e.set("triggerCount",0);return}let i=e.context.triggerElements.size,s={};e.state.triggerCount!==i&&(s.triggerCount=i);let a=e.select("activeTriggerId"),d=null;if(a){let c=e.context.triggerElements.getById(a);c?c!==e.state.activeTriggerElement&&(s.activeTriggerElement=c):d=a}if(!d&&!a&&i===1){let c=e.context.triggerElements.entries().next();if(!c.done){let[l,f]=c.value;s.activeTriggerId=l,s.activeTriggerElement=f}}(s.triggerCount!==void 0||s.activeTriggerId!==void 0||s.activeTriggerElement!==void 0)&&e.update(s),d&&o&&queueMicrotask(()=>{if(e.select("open")&&e.select("activeTriggerId")===d&&!e.context.triggerElements.getById(d)){let c=ee(U.none);e.setOpen(!1,c),c.isCanceled||e.update({activeTriggerId:null,activeTriggerElement:null})}})},[n,e,r,o])}function ol(e,t,o){let{mounted:n,setMounted:r,transitionStatus:i}=ha(e),s=t.useState("preventUnmountingOnClose"),a=e?!1:s;t.useSyncedValues({mounted:n,transitionStatus:i,preventUnmountingOnClose:a});let d=Y(()=>{r(!1),t.update({activeTriggerId:null,activeTriggerElement:null,mounted:!1,preventUnmountingOnClose:!1}),o?.(),t.context.onOpenChangeComplete?.(!1)});return Pn({enabled:n&&!e&&!a,open:e,ref:t.context.popupRef,onComplete(){e||d()}}),{forceUnmount:d,transitionStatus:i}}function nl(e,t){e.useSyncedValues(t),D(()=>()=>{e.update({activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be})},[e])}var jt=class{constructor(){this.elementsSet=new Set,this.idMap=new Map}add(t,o){let n=this.idMap.get(t);n!==o&&(n!==void 0&&this.elementsSet.delete(n),this.elementsSet.add(o),this.idMap.set(t,o))}delete(t){let o=this.idMap.get(t);o&&(this.elementsSet.delete(o),this.idMap.delete(t))}hasElement(t){return this.elementsSet.has(t)}hasMatchingElement(t){for(let o of this.elementsSet)if(t(o))return!0;return!1}getById(t){return this.idMap.get(t)}entries(){return this.idMap.entries()}elements(){return this.elementsSet.values()}get size(){return this.idMap.size}};function rl(){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:new jt,floatingId:void 0,syncOnly:!1,nested:!1,onOpenChange:void 0})}function sl(){return{open:!1,openProp:void 0,mounted:!1,transitionStatus:void 0,floatingRootContext:rl(),floatingId:void 0,triggerCount:0,preventUnmountingOnClose:!1,payload:void 0,activeTriggerId:null,activeTriggerElement:null,triggerIdProp:void 0,popupElement:null,positionerElement:null,activeTriggerProps:be,inactiveTriggerProps:be,popupProps:be}}function al(e,t,o=!1){return new pt({open:!1,transitionStatus:void 0,floatingElement:null,referenceElement:null,triggerElements:e,floatingId:t,syncOnly:!0,nested:o,onOpenChange:void 0})}var Ko=q(e=>e.triggerIdProp??e.activeTriggerId),di=q(e=>e.openProp??e.open),il=q(e=>(e.popupElement?.id??e.floatingId)||void 0);function cl(e,t){return t!==void 0&&di(e)&&Ko(e)===t}function $m(e,t){return cl(e,t)?!0:t!==void 0&&di(e)&&Ko(e)==null&&e.triggerCount===1}var ll={open:di,mounted:q(e=>e.mounted),transitionStatus:q(e=>e.transitionStatus),floatingRootContext:q(e=>e.floatingRootContext),triggerCount:q(e=>e.triggerCount),preventUnmountingOnClose:q(e=>e.preventUnmountingOnClose),payload:q(e=>e.payload),activeTriggerId:Ko,activeTriggerElement:q(e=>e.mounted?e.activeTriggerElement:null),popupId:il,isTriggerActive:q((e,t)=>t!==void 0&&Ko(e)===t),isOpenedByTrigger:q((e,t)=>cl(e,t)),isMountedByTrigger:q((e,t)=>t!==void 0&&Ko(e)===t&&e.mounted),triggerProps:q((e,t)=>t?e.activeTriggerProps:e.inactiveTriggerProps),triggerPopupId:q((e,t)=>$m(e,t)?il(e):void 0),popupProps:q(e=>e.popupProps),popupElement:q(e=>e.popupElement),positionerElement:q(e=>e.positionerElement)};function dl(e){let{open:t=!1,onOpenChange:o,elements:n={}}=e,r=Lt(),i=bo()!=null,s=Se(()=>new pt({open:t,transitionStatus:void 0,onOpenChange:o,referenceElement:n.reference??null,floatingElement:n.floating??null,triggerElements:new jt,floatingId:r,syncOnly:!1,nested:i})).current;return D(()=>{let a={open:t,floatingId:r};n.reference!==void 0&&(a.referenceElement=n.reference,a.domReferenceElement=V(n.reference)?n.reference:null),n.floating!==void 0&&(a.floatingElement=n.floating),s.update(a)},[t,r,n.reference,n.floating,s]),s.context.onOpenChange=o,s.context.nested=i,s}function ui(e={}){let{nodeId:t,externalTree:o}=e,n=dl(e),r=e.rootContext||n,i=r.useState("referenceElement"),s=r.useState("floatingElement"),a=r.useState("domReferenceElement"),d=r.useState("open"),c=r.useState("floatingId"),[l,f]=Ne.useState(null),[p,m]=Ne.useState(void 0),[u,g]=Ne.useState(void 0),v=Ne.useRef(null),_=Dt(o),w=Ne.useMemo(()=>({reference:i,floating:s,domReference:a}),[i,s,a]),y=Lc({...e,elements:{...w,...l&&{reference:l}}}),b=V(p)?p:null,S=u===void 0?r.state.floatingElement:u;r.useSyncedValue("referenceElement",p??null),r.useSyncedValue("domReferenceElement",p===void 0?a:b),r.useSyncedValue("floatingElement",S);let x=Ne.useCallback(A=>{let L=V(A)?{getBoundingClientRect:()=>A.getBoundingClientRect(),getClientRects:()=>A.getClientRects(),contextElement:A}:A;f(L),y.refs.setReference(L)},[y.refs]),E=Ne.useCallback(A=>{(V(A)||A===null)&&(v.current=A,m(A)),(V(y.refs.reference.current)||y.refs.reference.current===null||A!==null&&!V(A))&&y.refs.setReference(A)},[y.refs,m]),T=Ne.useCallback(A=>{g(A),y.refs.setFloating(A)},[y.refs]),k=Ne.useMemo(()=>({...y.refs,setReference:E,setFloating:T,setPositionReference:x,domReference:v}),[y.refs,E,T,x]),C=Ne.useMemo(()=>({...y.elements,domReference:a}),[y.elements,a]),j=Ne.useMemo(()=>({...y,dataRef:r.context.dataRef,open:d,onOpenChange:r.setOpen,events:r.context.events,floatingId:c,refs:k,elements:C,nodeId:t,rootStore:r}),[y,k,C,t,r,d,c]);return D(()=>{a&&(v.current=a)},[a]),D(()=>{r.context.dataRef.current.floatingContext=j;let A=_?.nodesRef.current.find(L=>L.id===t);A&&(A.context=j)}),Ne.useMemo(()=>({...y,context:j,refs:k,elements:C,rootStore:r}),[y,k,C,j,r])}var mt=h(z(),1);var fi=xt.os.mac&&xt.engine.webkit;function pi(e,t={}){let{enabled:o=!0,delay:n}=t,r="rootStore"in e?e.rootStore:e,{events:i,dataRef:s}=r.context,a=mt.useRef(!1),d=mt.useRef(null),c=mt.useRef(!0),l=rt();mt.useEffect(()=>{let p=r.select("domReferenceElement");if(!o)return;let m=ge(p);function u(){let _=r.select("domReferenceElement");!r.select("open")&&we(_)&&_===Cn(xe(_))&&(a.current=!0)}function g(){c.current=!0}function v(){c.current=!1}return it(re(m,"blur",u),fi&&re(m,"keydown",g,!0),fi&&re(m,"pointerdown",v,!0))},[r,o]),mt.useEffect(()=>{if(!o)return;function p(m){if(m.reason===U.triggerPress||m.reason===U.escapeKey){let u=r.select("domReferenceElement");V(u)&&(d.current=u,a.current=!0)}}return i.on("openchange",p),()=>{i.off("openchange",p)}},[i,o,r]);let f=mt.useMemo(()=>{function p(){a.current=!1,d.current=null}return{onMouseLeave(){p()},onFocus(m){let u=m.currentTarget;if(a.current){if(d.current===u)return;p()}let g=Me(m.nativeEvent);if(V(g)){if(fi&&!m.relatedTarget){if(!c.current&&!Da(g))return}else if(!ja(g))return}let v=Bt(m.relatedTarget,r.context.triggerElements),{nativeEvent:_,currentTarget:w}=m,y=typeof n=="function"?n():n;if(r.select("open")&&v||y===0||y===void 0){r.setOpen(!0,ee(U.triggerFocus,_,w));return}l.start(y,()=>{a.current||r.setOpen(!0,ee(U.triggerFocus,_,w))})},onBlur(m){p();let u=m.relatedTarget,g=m.nativeEvent,v=V(u)&&u.hasAttribute(go("focus-guard"))&&u.getAttribute("data-type")==="outside";l.start(0,()=>{let _=r.select("domReferenceElement"),w=Cn(xe(_));!u&&w===_||ie(s.current.floatingContext?.refs.floating.current,w)||ie(_,w)||v||Bt(u??w,r.context.triggerElements)||r.setOpen(!1,ee(U.triggerFocus,g))})}}},[s,n,r,l]);return mt.useMemo(()=>o?{reference:f,trigger:f}:{},[o,f])}var gi=h(z(),1);var mi=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new Ye,this.restTimeout=new Ye,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Vn=new WeakMap;function yo(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Vn.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty("pointer-events"),e.pointerEventsReferenceElement?.style.removeProperty("pointer-events"),e.pointerEventsFloatingElement?.style.removeProperty("pointer-events"),Vn.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Wn(e,t){let{scopeElement:o,referenceElement:n,floatingElement:r}=t,i=Vn.get(o);i&&i!==e&&yo(i),yo(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=o,e.pointerEventsReferenceElement=n,e.pointerEventsFloatingElement=r,Vn.set(o,e),o.style.pointerEvents="none",n.style.pointerEvents="auto",r.style.pointerEvents="auto"}function xo(e){let t=e.context.dataRef.current,o=Se(()=>t.hoverInteractionState??mi.create()).current;return t.hoverInteractionState||(t.hoverInteractionState=o),co(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function bi(e,t={}){let{enabled:o=!0,closeDelay:n=0,nodeId:r}=t,i="rootStore"in e?e.rootStore:e,s=i.useState("open"),a=i.useState("floatingElement"),d=i.useState("domReferenceElement"),{dataRef:c}=i.context,l=Dt(),f=bo(),p=xo(i),m=rt(),u=Y(()=>On(c.current.openEvent?.type,p.interactedInside)),g=Y(()=>Fa(c.current.openEvent?.type)),v=Y(()=>{yo(p)});D(()=>{s||(p.pointerType=void 0,p.restTimeoutPending=!1,p.interactedInside=!1,v())},[s,p,v]),gi.useEffect(()=>v,[v]),D(()=>{if(o&&s&&p.handleCloseOptions?.blockPointerEvents&&g()&&V(d)&&a){let _=d,w=a,y=xe(a),b=l?.nodesRef.current.find(T=>T.id===f)?.context?.elements.floating;b&&(b.style.pointerEvents="");let S=p.pointerEventsScopeElement!==w?p.pointerEventsScopeElement:null,x=b!==w?b:null,E=p.handleCloseOptions?.getScope?.()??S??x??_.closest("[data-rootownerid]")??y.body;return Wn(p,{scopeElement:E,referenceElement:_,floatingElement:w}),()=>{v()}}},[o,s,d,a,p,g,l,f,v]),gi.useEffect(()=>{if(!o)return;function _(){return!!(l&&f&&Et(l.nodesRef.current,f).length>0)}function w(T){let k=St(n,"close",p.pointerType),C=()=>{i.setOpen(!1,ee(U.triggerHover,T)),l?.events.emit("floating.closed",T)};k?p.openChangeTimeout.start(k,C):(p.openChangeTimeout.clear(),C())}function y(T){let k=Me(T);if(!Fr(k)){p.interactedInside=!1;return}p.interactedInside=k?.closest("[aria-haspopup]")!=null}function b(){p.openChangeTimeout.clear(),m.clear(),l?.events.off("floating.closed",x),v()}function S(T){if(_()&&l){l.events.on("floating.closed",x);return}if(Bt(T.relatedTarget,i.context.triggerElements))return;let k=c.current.floatingContext?.nodeId??r,C=T.relatedTarget;if(!(l&&k&&V(C)&&Et(l.nodesRef.current,k,!1).some(A=>ie(A.context?.elements.floating,C)))){if(p.handler){p.handler(T);return}v(),g()&&!u()&&w(T)}}function x(T){!l||!f||_()||m.start(0,()=>{l.events.off("floating.closed",x),i.setOpen(!1,ee(U.triggerHover,T)),l.events.emit("floating.closed",T)})}let E=a;return it(E&&re(E,"mouseenter",b),E&&re(E,"mouseleave",S),E&&re(E,"pointerdown",y,!0),()=>{l?.events.off("floating.closed",x)})},[o,a,i,c,n,r,g,u,v,p,l,f,m])}var Ft=h(z(),1),ul=h(Mt(),1);var eg={current:null};function hi(e,t={}){let{enabled:o=!0,delay:n=0,handleClose:r=null,mouseOnly:i=!1,restMs:s=0,move:a=!0,triggerElementRef:d=eg,externalTree:c,isActiveTrigger:l=!0,getHandleCloseContext:f,isClosing:p,shouldOpen:m}=t,u="rootStore"in e?e.rootStore:e,{dataRef:g,events:v}=u.context,_=Dt(c),w=xo(u),y=Ft.useRef(!1),b=ze(r),S=ze(n),x=ze(s),E=ze(o),T=ze(m),k=ze(p),C=Y(()=>On(g.current.openEvent?.type,w.interactedInside)),j=Y(()=>T.current?.()!==!1),A=Y((R,N,H)=>{let P=u.context.triggerElements;if(P.hasElement(N))return!R||!ie(R,N);if(!V(H))return!1;let O=H;return P.hasMatchingElement(M=>ie(M,O))&&(!R||!ie(R,O))}),L=Y(()=>{if(!w.handler)return;xe(u.select("domReferenceElement")).removeEventListener("mousemove",w.handler),w.handler=void 0}),I=Y(()=>{yo(w)});return l&&(w.handleCloseOptions=b.current?.__options),Ft.useEffect(()=>L,[L]),Ft.useEffect(()=>{if(!o)return;function R(N){N.open?y.current=!1:(y.current=N.reason===U.triggerHover,L(),w.openChangeTimeout.clear(),w.restTimeout.clear(),w.blockMouseMove=!0,w.restTimeoutPending=!1)}return v.on("openchange",R),()=>{v.off("openchange",R)}},[o,v,w,L]),Ft.useEffect(()=>{if(!o)return;function R(O,M=!0){let Z=St(S.current,"close",w.pointerType);Z?w.openChangeTimeout.start(Z,()=>{u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O)}):M&&(w.openChangeTimeout.clear(),u.setOpen(!1,ee(U.triggerHover,O)),_?.events.emit("floating.closed",O))}let N=d.current??(l?u.select("domReferenceElement"):null);if(!V(N))return;function H(O){if(w.openChangeTimeout.clear(),w.blockMouseMove=!1,i&&!Rt(w.pointerType))return;let M=Vr(x.current),Z=St(S.current,"open",w.pointerType),W=Me(O),oe=O.currentTarget??null,te=u.select("domReferenceElement"),se=oe;if(V(W)&&!u.context.triggerElements.hasElement(W)){for(let ue of u.context.triggerElements.elements())if(ie(ue,W)){se=ue;break}}V(oe)&&V(te)&&!u.context.triggerElements.hasElement(oe)&&ie(oe,te)&&(se=te);let G=se==null?!1:A(te,se,W),K=u.select("open"),J=k.current?.()??u.select("transitionStatus")==="ending",ne=!K&&J&&y.current,me=!G&&V(se)&&V(te)&&ie(te,se)&&ne,le=M>0&&!Z,X=G&&(K||ne)||me,pe=!K||G;if(X){j()&&u.setOpen(!0,ee(U.triggerHover,O,se));return}le||(Z?w.openChangeTimeout.start(Z,()=>{pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se))}):pe&&j()&&u.setOpen(!0,ee(U.triggerHover,O,se)))}function P(O){if(C()){I();return}L();let M=u.select("domReferenceElement"),Z=xe(M);w.restTimeout.clear(),w.restTimeoutPending=!1;let W=g.current.floatingContext??f?.();if(Bt(O.relatedTarget,u.context.triggerElements))return;if(b.current&&W){u.select("open")||w.openChangeTimeout.clear();let te=d.current;w.handler=b.current({...W,tree:_,x:O.clientX,y:O.clientY,onClose(){I(),L(),E.current&&!C()&&te===u.select("domReferenceElement")&&R(O,!0)}}),Z.addEventListener("mousemove",w.handler),w.handler(O);return}(w.pointerType!=="touch"||!ie(u.select("floatingElement"),O.relatedTarget))&&R(O)}return a?it(re(N,"mousemove",H,{once:!0}),re(N,"mouseenter",H),re(N,"mouseleave",P)):it(re(N,"mouseenter",H),re(N,"mouseleave",P))},[L,I,g,S,u,o,b,w,l,A,C,i,a,x,d,_,E,f,k,j]),Ft.useMemo(()=>{if(!o)return;function R(N){w.pointerType=N.pointerType}return{onPointerDown:R,onPointerEnter:R,onMouseMove(N){let{nativeEvent:H}=N,P=N.currentTarget,O=u.select("domReferenceElement"),M=u.select("open"),Z=A(O,P,N.target);if(i&&!Rt(w.pointerType))return;if(M&&Z&&w.handleCloseOptions?.blockPointerEvents){let te=u.select("floatingElement");if(te){let se=w.handleCloseOptions?.getScope?.()??P.ownerDocument.body;Wn(w,{scopeElement:se,referenceElement:P,floatingElement:te})}}let W=Vr(x.current);if(M&&!Z||W===0||!Z&&w.restTimeoutPending&&N.movementX**2+N.movementY**2<2)return;w.restTimeout.clear();function oe(){if(w.restTimeoutPending=!1,C())return;let te=u.select("open");!w.blockMouseMove&&(!te||Z)&&j()&&u.setOpen(!0,ee(U.triggerHover,H,P))}w.pointerType==="touch"?ul.flushSync(()=>{oe()}):Z&&M?oe():(w.restTimeoutPending=!0,w.restTimeout.start(W,oe))}}},[o,w,C,A,i,u,x,j])}var fl=.1,tg=fl*fl,ce=.5;function Yn(e,t,o,n,r,i){return n>=t!=i>=t&&e<=(r-o)*(t-n)/(i-n)+o}function Un(e,t,o,n,r,i,s,a,d,c){let l=!1;return Yn(e,t,o,n,r,i)&&(l=!l),Yn(e,t,r,i,s,a)&&(l=!l),Yn(e,t,s,a,d,c)&&(l=!l),Yn(e,t,d,c,o,n)&&(l=!l),l}function og(e,t,o){return e>=o.x&&e<=o.x+o.width&&t>=o.y&&t<=o.y+o.height}function Gn(e,t,o,n,r,i){let s=Math.min(o,r),a=Math.max(o,r),d=Math.min(n,i),c=Math.max(n,i);return e>=s&&e<=a&&t>=d&&t<=c}function wi(e={}){let{blockPointerEvents:t=!1}=e,o=new Ye,n=({x:r,y:i,placement:s,elements:a,onClose:d,nodeId:c,tree:l})=>{let f=s?.split("-")[0],p=!1,m=null,u=null,g=typeof performance<"u"?performance.now():0;function v(w,y){let b=performance.now(),S=b-g;if(m===null||u===null||S===0)return m=w,u=y,g=b,!1;let x=w-m,E=y-u,T=x*x+E*E,k=S*S*tg;return m=w,u=y,g=b,T0)}function L(){A()||_()}if(A())return;let I=b.getBoundingClientRect(),R=S.getBoundingClientRect(),N=r>R.right-R.width/2,H=i>R.bottom-R.height/2,P=R.width>I.width,O=R.height>I.height,M=(P?I:R).left,Z=(P?I:R).right,W=(O?I:R).top,oe=(O?I:R).bottom;if(f==="top"&&i>=I.bottom-1||f==="bottom"&&i<=I.top+1||f==="left"&&r>=I.right-1||f==="right"&&r<=I.left+1){L();return}let te=!1;switch(f){case"top":te=Gn(x,E,M,I.top+1,Z,R.bottom-1);break;case"bottom":te=Gn(x,E,M,R.top+1,Z,I.bottom-1);break;case"left":te=Gn(x,E,R.right-1,oe,I.left+1,W);break;case"right":te=Gn(x,E,I.right-1,oe,R.left+1,W);break;default:}if(te)return;if(p&&!og(x,E,I)){L();return}if(!k&&v(x,E)){L();return}let se=!1;switch(f){case"top":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i+ce+1,me=N||P?R.bottom-ce:R.top,le=N?P?R.bottom-ce:R.top:R.bottom-ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"bottom":{let G=P?ce/2:ce*4,K=P||N?r+G:r-G,J=P?r-G:N?r+G:r-G,ne=i-ce,me=N||P?R.top+ce:R.bottom,le=N?P?R.top+ce:R.bottom:R.top+ce;se=Un(x,E,K,ne,J,ne,R.left,me,R.right,le);break}case"left":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r+ce+1,me=H||O?R.right-ce:R.left,le=H?O?R.right-ce:R.left:R.right-ce;se=Un(x,E,me,R.top,le,R.bottom,ne,K,ne,J);break}case"right":{let G=O?ce/2:ce*4,K=O||H?i+G:i-G,J=O?i-G:H?i+G:i-G,ne=r-ce,me=H||O?R.left+ce:R.right,le=H?O?R.left+ce:R.right:R.left+ce;se=Un(x,E,ne,K,ne,J,me,R.top,le,R.bottom);break}default:}se?p||o.start(40,L):L()}};return n.__options={...e,blockPointerEvents:t},n}var vi=(function(e){return e.open="data-open",e.closed="data-closed",e[e.startingStyle=Yt.startingStyle]="startingStyle",e[e.endingStyle=Yt.endingStyle]="endingStyle",e.anchorHidden="data-anchor-hidden",e.side="data-side",e.align="data-align",e})({}),qo=(function(e){return e.popupOpen="data-popup-open",e.pressed="data-pressed",e})({}),ng={[qo.popupOpen]:""},B_={[qo.popupOpen]:"",[qo.pressed]:""},rg={[vi.open]:""},ig={[vi.closed]:""},sg={[vi.anchorHidden]:""},pl={open(e){return e?ng:null}};var Ro={open(e){return e?rg:ig},anchorHidden(e){return e?sg:null}};function ml(e){return ao(19)?e:e?"true":void 0}var Ge=h(z(),1);var ag=e=>({name:"arrow",options:e,async fn(t){let{x:o,y:n,placement:r,rects:i,platform:s,elements:a,middlewareData:d}=t,{element:c,padding:l=0,offsetParent:f="real"}=at(e,t)||{};if(c==null)return{};let p=In(l),m={x:o,y:n},u=Go(r),g=Uo(u),v=await s.getDimensions(c),_=u==="y",w=_?"top":"left",y=_?"bottom":"right",b=_?"clientHeight":"clientWidth",S=i.reference[g]+i.reference[u]-m[u]-i.floating[g],x=m[u]-i.reference[u],E=f==="real"?await s.getOffsetParent?.(c):a.floating,T=a.floating[b]||i.floating[g];(!T||!await s.isElement?.(E))&&(T=a.floating[b]||i.floating[g]);let k=S/2-x/2,C=T/2-v[g]/2-1,j=Math.min(p[w],C),A=Math.min(p[y],C),L=j,I=T-v[g]-A,R=T/2-v[g]/2+k,N=Yo(L,R,I),H=!d.arrow&&ct(r)!=null&&R!==N&&i.reference[g]/2-(R({...ag(e),options:[e,t]});var cg=ni().fn,bl={name:"hide",async fn(e){let{width:t,height:o,x:n,y:r}=e.rects.reference,i=t===0&&o===0&&n===0&&r===0;return{data:{referenceHidden:(await cg(e)).data?.referenceHidden||i}}}};var Zo={sideX:"left",sideY:"top"},hl={name:"adaptiveOrigin",async fn(e){let{x:t,y:o,rects:{floating:n},elements:{floating:r},platform:i,strategy:s,placement:a}=e,d=ge(r),c=d.getComputedStyle(r);if(!(c.transitionDuration!=="0s"&&c.transitionDuration!==""))return{x:t,y:o,data:Zo};let f=await i.getOffsetParent?.(r),p={width:0,height:0};if(s==="fixed"&&d?.visualViewport)p={width:d.visualViewport.width,height:d.visualViewport.height};else if(f===d){let w=xe(r);p={width:w.documentElement.clientWidth,height:w.documentElement.clientHeight}}else await i.isElement?.(f)&&(p=await i.getDimensions(f));let m=Ee(a),u=t,g=o;m==="left"&&(u=p.width-(t+n.width)),m==="top"&&(g=p.height-(o+n.height));let v=m==="left"?"right":Zo.sideX,_=m==="top"?"bottom":Zo.sideY;return{x:u,y:g,data:{sideX:v,sideY:_}}}};function _l(e,t,o){let n=e==="inline-start"||e==="inline-end";return{top:"top",right:n?o?"inline-start":"inline-end":"right",bottom:"bottom",left:n?o?"inline-end":"inline-start":"left"}[t]}function wl(e,t,o){let{rects:n,placement:r}=e;return{side:_l(t,Ee(r),o),align:ct(r)||"center",anchor:{width:n.reference.width,height:n.reference.height},positioner:{width:n.floating.width,height:n.floating.height}}}function yl(e){let{anchor:t,positionMethod:o="absolute",side:n="bottom",sideOffset:r=0,align:i="center",alignOffset:s=0,collisionBoundary:a,collisionPadding:d=5,sticky:c=!1,arrowPadding:l=5,disableAnchorTracking:f=!1,inline:p,keepMounted:m=!1,floatingRootContext:u,mounted:g,collisionAvoidance:v,shiftCrossAxis:_=!1,nodeId:w,adaptiveOrigin:y,lazyFlip:b=!1,externalTree:S}=e,[x,E]=Ge.useState(null);!g&&x!==null&&E(null);let T=v.side||"flip",k=v.align||"flip",C=v.fallbackAxisSide||"end",j=typeof t=="function"?t:void 0,A=Y(j),L=j?A:t,I=ze(t),R=ze(g),H=so()==="rtl",P=x||{top:"top",right:"right",bottom:"bottom",left:"left","inline-end":H?"left":"right","inline-start":H?"right":"left"}[n],O=i==="center"?P:`${P}-${i}`,M=d,Z=1,W=n==="bottom"?Z:0,oe=n==="top"?Z:0,te=n==="right"?Z:0,se=n==="left"?Z:0;typeof M=="number"?M={top:M+W,right:M+se,bottom:M+oe,left:M+te}:M&&(M={top:(M.top||0)+W,right:(M.right||0)+se,bottom:(M.bottom||0)+oe,left:(M.left||0)+te});let G={boundary:a==="clipping-ancestors"?"clippingAncestors":a,padding:M},K=Ge.useRef(null),J=ze(r),ne=ze(s),me=typeof r!="function"?r:0,le=typeof s!="function"?s:0,X=[];p&&X.push(p),X.push(Jr(ae=>{let Ie=wl(ae,n,H),ut=typeof J.current=="function"?J.current(Ie):J.current,qe=typeof ne.current=="function"?ne.current(Ie):ne.current;return{mainAxis:ut,crossAxis:qe,alignmentAxis:qe}},[me,le,H,n]));let pe=k==="none"&&T!=="shift",ue=!pe&&(c||_||T==="shift"),vt=T==="none"?null:ti({...G,padding:{top:M.top+Z,right:M.right+Z,bottom:M.bottom+Z,left:M.left+Z},mainAxis:!_&&T==="flip",crossAxis:k==="flip"?"alignment":!1,fallbackAxisSideDirection:C}),Te=pe?null:$r(ae=>{let Ie=xe(ae.elements.floating).documentElement;return{...G,rootBoundary:_?{x:0,y:0,width:Ie.clientWidth,height:Ie.clientHeight}:void 0,mainAxis:k!=="none",crossAxis:ue,limiter:c||_?void 0:ei(ut=>{if(!K.current)return{};let{width:qe,height:yt}=K.current.getBoundingClientRect(),et=De(Ee(ut.placement)),Vt=et==="y"?qe:yt,io=et==="y"?M.left+M.right:M.top+M.bottom;return{offset:Vt/2+io/2}})}},[G,c,_,M,k]);T==="shift"||k==="shift"||i==="center"?X.push(Te,vt):X.push(vt,Te),X.push(oi({...G,apply({elements:{floating:ae},availableWidth:Ie,availableHeight:ut,rects:qe}){if(!R.current)return;let yt=ae.style;yt.setProperty("--available-width",`${Ie}px`),yt.setProperty("--available-height",`${ut}px`);let et=ge(ae).devicePixelRatio||1,{x:Vt,y:io,width:bn,height:br}=qe.reference,hr=(Math.round((Vt+bn)*et)-Math.round(Vt*et))/et,wr=(Math.round((io+br)*et)-Math.round(io*et))/et;yt.setProperty("--anchor-width",`${hr}px`),yt.setProperty("--anchor-height",`${wr}px`)}}),gl(ae=>({element:K.current||xe(ae.elements.floating).createElement("div"),padding:l,offsetParent:"floating"}),[l]),{name:"transformOrigin",fn(ae){let{elements:Ie,middlewareData:ut,placement:qe,rects:yt,y:et}=ae,Vt=Ee(qe),io=De(Vt),bn=K.current,br=ut.arrow?.x||0,hr=ut.arrow?.y||0,wr=bn?.clientWidth||0,ff=bn?.clientHeight||0,vr=br+wr/2,Us=hr+ff/2,pf=Math.abs(ut.shift?.y||0),mf=yt.reference.height/2,Io=typeof r=="function"?r(wl(ae,n,H)):r,gf=pf>Io,bf={top:`${vr}px calc(100% + ${Io}px)`,bottom:`${vr}px ${-Io}px`,left:`calc(100% + ${Io}px) ${Us}px`,right:`${-Io}px ${Us}px`}[Vt],hf=`${vr}px ${yt.reference.y+mf-et}px`;return Ie.floating.style.setProperty("--transform-origin",ue&&io==="y"&&gf?hf:bf),{}}},bl,y),D(()=>{!g&&u&&u.update({referenceElement:null,floatingElement:null,domReferenceElement:null,positionReference:null})},[g,u]);let Ve=Ge.useMemo(()=>({elementResize:!f&&typeof ResizeObserver<"u",layoutShift:!f&&typeof IntersectionObserver<"u"}),[f]),{refs:Ke,elements:He,x:no,y:dn,middlewareData:_e,update:ro,placement:B,context:F,isPositioned:he,floatingStyles:ke}=ui({rootContext:u,open:m?g:void 0,placement:O,middleware:X,strategy:o,whileElementsMounted:m?void 0:(...ae)=>Xo(...ae,Ve),nodeId:w,externalTree:S}),{sideX:kt,sideY:Lo}=_e.adaptiveOrigin||Zo,_t=he?o:"fixed",We=Ge.useMemo(()=>{let ae=y?{position:_t,[kt]:no,[Lo]:dn}:{position:_t,...ke};return he||(ae.opacity=0),ae},[y,_t,kt,no,Lo,dn,ke,he]),Pt=Ge.useRef(null);D(()=>{if(!g)return;let ae=I.current,Ie=typeof ae=="function"?ae():ae,qe=(vl(Ie)?Ie.current:Ie)||null||null;qe!==Pt.current&&(Ke.setPositionReference(qe),Pt.current=qe)},[g,Ke,L,I]),Ge.useEffect(()=>{if(!g)return;let ae=I.current;typeof ae!="function"&&vl(ae)&&ae.current!==Pt.current&&(Ke.setPositionReference(ae.current),Pt.current=ae.current)},[g,Ke,L,I]),Ge.useEffect(()=>{if(m&&g&&He.reference&&He.floating)return Xo(He.reference,He.floating,ro,Ve)},[m,g,He,ro,Ve]);let Ct=Ee(B),un=_l(n,Ct,H),fn=ct(B)||"center",pn=!!_e.hide?.referenceHidden;D(()=>{b&&g&&he&&E(Ct)},[b,g,he,Ct]);let mn=Ge.useMemo(()=>({position:"absolute",top:_e.arrow?.y,left:_e.arrow?.x}),[_e.arrow]),gn=_e.arrow?.centerOffset!==0;return Ge.useMemo(()=>({positionerStyles:We,arrowStyles:mn,arrowRef:K,arrowUncentered:gn,side:un,align:fn,physicalSide:Ct,anchorHidden:pn,refs:Ke,context:F,isPositioned:he,update:ro}),[We,mn,K,gn,un,fn,Ct,pn,Ke,F,he,ro])}function vl(e){return e!=null&&"current"in e}function Xn(e){return e==="starting"?Za:be}function xl(e,t,{styles:o,transitionStatus:n,props:r,refs:i,hidden:s,inert:a=!1}){let d={...o};return a&&(d.pointerEvents="none"),Ce("div",e,{state:t,ref:i,props:[{role:"presentation",hidden:s,style:d},Xn(n),r],stateAttributesMapping:Ro})}var Rl=h(z(),1);var _i=Rl.forwardRef(function(t,o){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:s=!1,nativeButton:a=!0,style:d,...c}=t,{getButtonProps:l,buttonRef:f}=Ea({disabled:i,focusableWhenDisabled:s,native:a});return Ce("button",t,{state:{disabled:i},ref:[o,f],props:[c,l]})});var Le=h(z(),1),Cl=h(Mt(),1);var Sl=h(z(),1);function El(e){let[t,o]=Sl.useState({current:e,previous:null});return e!==t.current&&o({current:e,previous:t.current}),t.previous}var So=h(z(),1);function yi(e){let t=Ae(e),o=parseFloat(t.width)||0,n=parseFloat(t.height)||0,r=we(e),i=r?e.offsetWidth:o,s=r?e.offsetHeight:n;return(zt(o)!==i||zt(n)!==s)&&(o=i,n=s),{width:o,height:n}}function kl(e){let{popupElement:t,positionerElement:o,content:n,mounted:r,onMeasureLayout:i,onMeasureLayoutComplete:s,side:a,direction:d}=e,c=mo(t,!0,!1),l=lo(),f=So.useRef(null),p=So.useRef(!0),m=So.useRef(Nt),u=Y(i),g=Y(s),v=So.useMemo(()=>{let _=a==="top",w=a==="left";return d==="rtl"?(_=_||a==="inline-end",w=w||a==="inline-end"):(_=_||a==="inline-start",w=w||a==="inline-start"),_?{position:"absolute",[a==="top"?"bottom":"top"]:"0",[w?"right":"left"]:"0"}:be},[a,d]);D(()=>{if(!r){m.current=Nt,p.current=!0,f.current=null;return}if(!t||!o)return;m.current=Tl(t,v),xi(t,"auto");let _=qn(t,"position","static"),w=qn(t,"transform","none"),y=qn(t,"scale","1"),b=Tl(o,{"--available-width":"max-content","--available-height":"max-content"});function S(){_(),w(),b()}function x(){S(),y()}if(u?.(),p.current||f.current===null){Kn(o,"max-content");let C=yi(t);return f.current=C,Kn(o,C),x(),g?.(null,C),p.current=!1,()=>{m.current(),m.current=Nt}}Kn(o,"max-content");let E=f.current,T=yi(t);f.current=T,xi(t,E),x(),g?.(E,T),Kn(o,T);let k=new AbortController;return l.request(()=>{xi(t,T),c(()=>{t.style.setProperty("--popup-width","auto"),t.style.setProperty("--popup-height","auto")},k.signal)}),()=>{k.abort(),l.cancel(),m.current(),m.current=Nt}},[n,t,o,c,l,r,u,g,v])}function qn(e,t,o){let n=e.style.getPropertyValue(t);return e.style.setProperty(t,o),()=>{e.style.setProperty(t,n)}}function Tl(e,t){let o=[];for(let[n,r]of Object.entries(t))o.push(qn(e,n,r));return o.length?()=>{o.forEach(n=>n())}:Nt}function xi(e,t){let o=t==="auto"?"auto":`${t.width}px`,n=t==="auto"?"auto":`${t.height}px`;e.style.setProperty("--popup-width",o),e.style.setProperty("--popup-height",n)}function Kn(e,t){let o=t==="max-content"?"max-content":`${t.width}px`,n=t==="max-content"?"max-content":`${t.height}px`;e.style.setProperty("--positioner-width",o),e.style.setProperty("--positioner-height",n)}var Eo=h(Q(),1);function Al(e){let{store:t,side:o,cssVars:n,children:r}=e,i=so(),s=t.useState("activeTriggerElement"),a=t.useState("activeTriggerId"),d=t.useState("open"),c=t.useState("payload"),l=t.useState("mounted"),f=t.useState("popupElement"),p=t.useState("positionerElement"),m=El(d?s:null),u=ug(a,c),g=Le.useRef(null),[v,_]=Le.useState(null),[w,y]=Le.useState(null),b=Le.useRef(null),S=Le.useRef(null),x=mo(b,!0,!1),E=lo(),[T,k]=Le.useState(null),[C,j]=Le.useState(!1);D(()=>(t.set("hasViewport",!0),()=>{t.set("hasViewport",!1)}),[t]);let A=Y(()=>{b.current?.style.setProperty("animation","none"),b.current?.style.setProperty("transition","none"),S.current?.style.setProperty("display","none")}),L=Y(P=>{b.current?.style.removeProperty("animation"),b.current?.style.removeProperty("transition"),S.current?.style.removeProperty("display"),P&&k(P)}),I=Le.useRef(null);D(()=>{(!d||!l)&&(I.current=null)},[d,l]),D(()=>{if(s&&m&&s!==m&&I.current!==s&&g.current){_(g.current),j(!0);let P=dg(m,s);y(P),E.request(()=>{Cl.flushSync(()=>{j(!1)}),x(()=>{_(null),k(null),g.current=null})}),I.current=s}},[s,m,v,x,E]),D(()=>{let P=b.current;if(!P)return;let O=xe(P).createElement("div");for(let M of Array.from(P.childNodes))O.appendChild(M.cloneNode(!0));g.current=O});let R=v!=null,N;R?N=(0,Eo.jsxs)(Le.Fragment,{children:[(0,Eo.jsx)("div",{"data-previous":!0,inert:ml(!0),ref:S,style:{...T?{[n.popupWidth]:`${T.width}px`,[n.popupHeight]:`${T.height}px`}:null,position:"absolute"},"data-ending-style":C?void 0:""},"previous"),(0,Eo.jsx)("div",{"data-current":!0,ref:b,"data-starting-style":C?"":void 0,children:r},u)]}):N=(0,Eo.jsx)("div",{"data-current":!0,ref:b,children:r},u),D(()=>{let P=S.current;!P||!v||P.replaceChildren(...Array.from(v.childNodes))},[v]),kl({popupElement:f,positionerElement:p,mounted:l,content:c,onMeasureLayout:A,onMeasureLayoutComplete:L,side:o,direction:i});let H={activationDirection:lg(w),transitioning:R};return{children:N,state:H}}function lg(e){if(e)return`${Pl(e.horizontal,5,"right","left")} ${Pl(e.vertical,5,"down","up")}`}function Pl(e,t,o,n){return e>t?o:e<-t?n:""}function dg(e,t){let o=e.getBoundingClientRect(),n=t.getBoundingClientRect(),r={x:o.left+o.width/2,y:o.top+o.height/2},i={x:n.left+n.width/2,y:n.top+n.height/2};return{horizontal:i.x-r.x,vertical:i.y-r.y}}function ug(e,t){let[o,n]=Le.useState(0),r=Le.useRef(e),i=Le.useRef(t),s=Le.useRef(!1);return D(()=>{let a=r.current,d=i.current,c=e!==a,l=t!==d;c?(n(f=>f+1),s.current=!l):s.current&&l&&(n(f=>f+1),s.current=!1),r.current=e,i.current=t},[e,t]),`${e??"current"}-${o}`}var Zn=h(z(),1),Ol=h(Mt(),1);var Nl=h(Q(),1),Ll=Zn.forwardRef(function(t,o){let{children:n,container:r,className:i,render:s,style:a,...d}=t,{portalNode:c,portalSubtree:l}=Ur({container:r,ref:o,componentProps:t,elementProps:d});return!l&&!c?null:(0,Nl.jsxs)(Zn.Fragment,{children:[l,c&&Ol.createPortal(n,c)]})});var Qe={};At(Qe,{Arrow:()=>ql,Handle:()=>Qo,Popup:()=>Xl,Portal:()=>Wl,Positioner:()=>Ul,Provider:()=>Zl,Root:()=>Ml,Trigger:()=>jl,Viewport:()=>$l,createHandle:()=>ed});var gt=h(z(),1);var Qn=h(z(),1),Ri=Qn.createContext(void 0);function Ze(e){let t=Qn.useContext(Ri);if(t===void 0&&!e)throw new Error(Pe(72));return t}var Il=h(z(),1);var fg={...ll,disabled:q(e=>e.disabled),instantType:q(e=>e.instantType),isInstantPhase:q(e=>e.isInstantPhase),trackCursorAxis:q(e=>e.trackCursorAxis),disableHoverablePopup:q(e=>e.disableHoverablePopup),lastOpenChangeReason:q(e=>e.openChangeReason),closeOnClick:q(e=>e.closeOnClick),closeDelay:q(e=>e.closeDelay),hasViewport:q(e=>e.hasViewport)},To=class e extends vo{constructor(t,o,n=!1){let r=new jt,i={...pg(),...t};i.floatingRootContext=al(r,o,n),super(i,{popupRef:Il.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:r},fg)}setOpen=(t,o)=>{Jc(this,t,o,{extraState:{openChangeReason:o.reason}})};cancelPendingOpen(t){this.state.floatingRootContext.dispatchOpenChange(!1,ee(U.triggerPress,t))}static useStore(t,o){return Qc(t,(r,i)=>new e(o,r,i)).store}};function pg(){return{...sl(),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:"none",disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,hasViewport:!1}}var Jn=h(Q(),1),Ml=ci(function(t){let{disabled:o=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:s="none",actionsRef:a,onOpenChange:d,onOpenChangeComplete:c,handle:l,triggerId:f,defaultTriggerId:p=null,children:m}=t,u=To.useStore(l?.store,{open:n,openProp:r,activeTriggerId:p,triggerIdProp:f});$c(u,r,n,p),u.useControlledProp("openProp",r),u.useControlledProp("triggerIdProp",f),u.useContextCallback("onOpenChange",d),u.useContextCallback("onOpenChangeComplete",c);let g=u.useState("open"),v=!o&&g,_=u.useState("activeTriggerId"),w=u.useState("mounted"),y=u.useState("payload");u.useSyncedValues({trackCursorAxis:s,disableHoverablePopup:i}),u.useSyncedValue("disabled",o),tl(u,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:b,transitionStatus:S}=ol(v,u),x=u.useState("isInstantPhase"),E=u.useState("instantType"),T=u.useState("lastOpenChangeReason"),k=gt.useRef(null);D(()=>{g&&o&&u.setOpen(!1,ee(U.disabled))},[g,o,u]),D(()=>{S==="ending"&&T===U.none||S!=="ending"&&x?(E!=="delay"&&(k.current=E),u.set("instantType","delay")):k.current!==null&&(u.set("instantType",k.current),k.current=null)},[S,x,T,E,u]),D(()=>{v&&_==null&&u.set("payload",void 0)},[u,_,v]);let C=gt.useCallback(()=>{u.setOpen(!1,ee(U.imperativeAction))},[u]);gt.useImperativeHandle(a,()=>({unmount:b,close:C}),[b,C]);let j=v||w||!o&&s!=="none";return(0,Jn.jsxs)(Ri.Provider,{value:u,children:[j&&(0,Jn.jsx)(mg,{store:u,disabled:o,trackCursorAxis:s}),typeof m=="function"?m({payload:y}):m]})});function mg({store:e,disabled:t,trackCursorAxis:o}){let n=e.useState("floatingRootContext"),r=Xr(n,{enabled:!t,referencePress:()=>e.select("closeOnClick")}),i=Gr(n,{enabled:!t&&o!=="none",axis:o==="none"?void 0:o}),s=gt.useMemo(()=>ye(i.reference,r.reference),[i.reference,r.reference]),a=gt.useMemo(()=>ye(i.trigger,r.trigger),[i.trigger,r.trigger]),d=gt.useMemo(()=>ye(Zc,i.floating,r.floating),[i.floating,r.floating]);return nl(e,{activeTriggerProps:s,inactiveTriggerProps:a,popupProps:d}),null}var er=h(z(),1);var $n=h(z(),1),Si=$n.createContext(void 0);function Bl(){return $n.useContext(Si)}var Hl=(function(e){return e[e.popupOpen=qo.popupOpen]="popupOpen",e.triggerDisabled="data-trigger-disabled",e})({});var Dl="data-base-ui-tooltip-trigger";function zl(e){if("composedPath"in e){let o=e.composedPath();for(let n=0;ng.select("transitionStatus")==="ending",shouldOpen(){return!O.current}}),G=pi(y,{enabled:!R}).reference,K=X=>{let pe=O.current,ue=zl(X),vt=te(ue),Te=b.current,Ve=Te&&ue&&ie(Te,ue);if(vt&&g.select("open")&&g.select("lastOpenChangeReason")===U.triggerHover){g.setOpen(!1,ee(U.triggerHover,X));return}if(pe&&!vt&&Ve&&!N.current&&!g.select("open")&&Te&&Rt(Z.current)){let Ke=()=>{!O.current&&!N.current&&!g.select("open")&&g.setOpen(!0,ee(U.triggerHover,X,Te))},He=W();He===0?(M.clear(),Ke()):M.start(He,Ke)}},J=g.useState("triggerProps",T);return Ce("button",t,{state:{open:w},ref:[o,E,b],props:[se,G,T||H!=="none"?J:void 0,{onMouseOver(X){K(X.nativeEvent)},onFocus(X){oe(zl(X.nativeEvent))&&X.preventBaseUIHandler()},onMouseLeave(){O.current=!1,M.clear(),Z.current=void 0},onPointerEnter(X){Z.current=X.pointerType},onPointerDown(X){Z.current=X.pointerType,g.set("closeOnClick",l),l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},onClick(X){l&&!g.select("open")&&g.cancelPendingOpen(X.nativeEvent)},id:v,[Hl.triggerDisabled]:R?"":void 0,[Dl]:R?void 0:""},m],stateAttributesMapping:pl})});var Vl=h(z(),1);var tr=h(z(),1),Ei=tr.createContext(void 0);function Fl(){let e=tr.useContext(Ei);if(e===void 0)throw new Error(Pe(70));return e}var Ti=h(Q(),1),Wl=Vl.forwardRef(function(t,o){let{keepMounted:n=!1,...r}=t;return Ze().useState("mounted")||n?(0,Ti.jsx)(Ei.Provider,{value:n,children:(0,Ti.jsx)(Ll,{ref:o,...r})}):null});var nr=h(z(),1);var or=h(z(),1),ki=or.createContext(void 0);function ko(){let e=or.useContext(ki);if(e===void 0)throw new Error(Pe(71));return e}var Yl=h(Q(),1),Ul=nr.forwardRef(function(t,o){let{render:n,className:r,anchor:i,positionMethod:s="absolute",side:a="top",align:d="center",sideOffset:c=0,alignOffset:l=0,collisionBoundary:f="clipping-ancestors",collisionPadding:p=5,arrowPadding:m=5,sticky:u=!1,disableAnchorTracking:g=!1,collisionAvoidance:v=Qa,style:_,...w}=t,y=Ze(),b=Fl(),S=y.useState("open"),x=y.useState("mounted"),E=y.useState("trackCursorAxis"),T=y.useState("disableHoverablePopup"),k=y.useState("floatingRootContext"),C=y.useState("instantType"),j=y.useState("transitionStatus"),A=y.useState("hasViewport"),L=yl({anchor:i,positionMethod:s,floatingRootContext:k,mounted:x,side:a,sideOffset:c,align:d,alignOffset:l,collisionBoundary:f,collisionPadding:p,sticky:u,arrowPadding:m,disableAnchorTracking:g,keepMounted:b,collisionAvoidance:v,adaptiveOrigin:A?hl:void 0}),I=nr.useMemo(()=>({open:S,side:L.side,align:L.align,anchorHidden:L.anchorHidden,instant:E!=="none"?"tracking-cursor":C}),[S,L.side,L.align,L.anchorHidden,E,C]),R=xl(t,I,{styles:L.positionerStyles,transitionStatus:j,props:w,refs:[o,y.useStateSetter("positionerElement")],hidden:!x,inert:!S||E==="both"||T});return(0,Yl.jsx)(ki.Provider,{value:L,children:R})});var Gl=h(z(),1);var bg={...Ro,...wa},Xl=Gl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{side:d,align:c}=ko(),l=a.useState("open"),f=a.useState("instantType"),p=a.useState("transitionStatus"),m=a.useState("popupProps"),u=a.useState("floatingRootContext"),g=a.useState("disabled"),v=a.useState("closeDelay");Pn({open:l,ref:a.context.popupRef,onComplete(){l&&a.context.onOpenChangeComplete?.(!0)}}),bi(u,{enabled:!g,closeDelay:v});let _=a.useStateSetter("popupElement");return Ce("div",t,{state:{open:l,side:d,align:c,instant:f,transitionStatus:p},ref:[o,a.context.popupRef,_],props:[m,Xn(p),s],stateAttributesMapping:bg})});var Kl=h(z(),1);var ql=Kl.forwardRef(function(t,o){let{render:n,className:r,style:i,...s}=t,a=Ze(),{arrowRef:d,side:c,align:l,arrowUncentered:f,arrowStyles:p}=ko(),m=a.useState("open"),u=a.useState("instantType");return Ce("div",t,{state:{open:m,side:c,align:l,uncentered:f,instant:u},ref:[o,d],props:[{style:p,"aria-hidden":!0},s],stateAttributesMapping:Ro})});var Pi=h(z(),1);var Ci=h(Q(),1),Zl=function(t){let{delay:o,closeDelay:n,timeout:r=400}=t,i=Pi.useMemo(()=>({delay:o,closeDelay:n}),[o,n]),s=Pi.useMemo(()=>({open:o,close:n}),[o,n]);return(0,Ci.jsx)(Si.Provider,{value:i,children:(0,Ci.jsx)(Wr,{delay:s,timeoutMs:r,children:t.children})})};var Jl=h(z(),1);var Ql=(function(e){return e.popupWidth="--popup-width",e.popupHeight="--popup-height",e})({});var hg={activationDirection:e=>e?{"data-activation-direction":e}:null},$l=Jl.forwardRef(function(t,o){let{render:n,className:r,style:i,children:s,...a}=t,d=Ze(),c=ko(),l=d.useState("instantType"),{children:f,state:p}=Al({store:d,side:c.side,cssVars:Ql,children:s}),m={activationDirection:p.activationDirection,transitioning:p.transitioning,instant:l};return Ce("div",t,{state:m,ref:o,props:[a,{children:f}],stateAttributesMapping:hg})});var Qo=class{constructor(){this.store=new To}open(t){let o=t?this.store.context.triggerElements.getById(t):void 0;if(t&&!o)throw new Error(Pe(81,t));this.store.setOpen(!0,ee(U.imperativeAction,void 0,o))}close(){this.store.setOpen(!1,ee(U.imperativeAction,void 0,void 0))}get isOpen(){return this.store.select("open")}};function ed(){return new Qo}function bt(e){return Ce(e.defaultTagName??"div",e,e)}var nd=h(de(),1),Ai="data-wp-hash";function Oi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vg(document)),e.__wpStyleRuntime}function wg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ai}]`))if(o.getAttribute(Ai)===t)return!0;return!1}function rd(e,t,o){if(!e.head)return;let n=Oi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ai,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vg(e){let t=Oi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)rd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function id(e,t){let o=Oi();o.styles.set(e,t);for(let n of o.documents.keys())rd(n,e,t)}typeof process>"u",id("a495f9d138",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._83ed8a8da5dd50ea__text{margin:0}._14437cfb77831647__heading-2xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-2xl,32px);--_gcd-p-line-height:var(--wpds-typography-line-height-2xl,40px);font-size:var(--wpds-typography-font-size-2xl,32px);line-height:var(--wpds-typography-line-height-2xl,40px)}._14437cfb77831647__heading-2xl,._3c78b7fa9b4072dd__heading-xl{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600)}._3c78b7fa9b4072dd__heading-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-md,24px)}.aa58f227716bcde2__heading-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-lg,15px)}.aa58f227716bcde2__heading-lg,.fc4da56d8dfe52c4__heading-md{font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-sm,20px)}.fc4da56d8dfe52c4__heading-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px)}.a9b78c7c82e8dff7__heading-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-emphasis,600);--_gcd-p-font-size:var(--wpds-typography-font-size-xs,11px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-family:var(--wpds-typography-font-family-heading,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-xs,11px);font-weight:var(--wpds-typography-font-weight-emphasis,600);line-height:var(--wpds-typography-line-height-xs,16px);text-transform:uppercase}._305ff559e52180d5__body-xl{--_gcd-heading-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-xl,20px);--_gcd-p-line-height:var(--wpds-typography-line-height-xl,32px);font-size:var(--wpds-typography-font-size-xl,20px);line-height:var(--wpds-typography-line-height-xl,32px)}._305ff559e52180d5__body-xl,.ca1aa3fc2029e958__body-lg{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}.ca1aa3fc2029e958__body-lg{--_gcd-heading-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-lg,15px);--_gcd-p-line-height:var(--wpds-typography-line-height-md,24px);font-size:var(--wpds-typography-font-size-lg,15px);line-height:var(--wpds-typography-line-height-md,24px)}._131101940be12424__body-md{--_gcd-heading-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-md,13px);--_gcd-p-line-height:var(--wpds-typography-line-height-sm,20px);font-size:var(--wpds-typography-font-size-md,13px);line-height:var(--wpds-typography-line-height-sm,20px)}._0e8d87a42c1f75fa__body-sm,._131101940be12424__body-md{font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-weight:var(--wpds-typography-font-weight-default,400)}._0e8d87a42c1f75fa__body-sm{--_gcd-heading-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-heading-font-weight:var(--wpds-typography-font-weight-default,400);--_gcd-p-font-size:var(--wpds-typography-font-size-sm,12px);--_gcd-p-line-height:var(--wpds-typography-line-height-xs,16px);font-size:var(--wpds-typography-font-size-sm,12px);line-height:var(--wpds-typography-line-height-xs,16px)}}}');var td={text:"_83ed8a8da5dd50ea__text","heading-2xl":"_14437cfb77831647__heading-2xl","heading-xl":"_3c78b7fa9b4072dd__heading-xl","heading-lg":"aa58f227716bcde2__heading-lg","heading-md":"fc4da56d8dfe52c4__heading-md","heading-sm":"a9b78c7c82e8dff7__heading-sm","body-xl":"_305ff559e52180d5__body-xl","body-lg":"ca1aa3fc2029e958__body-lg","body-md":"_131101940be12424__body-md","body-sm":"_0e8d87a42c1f75fa__body-sm"};typeof process>"u",id("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var od={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},Je=(0,nd.forwardRef)(function({variant:t="body-md",render:o,className:n,...r},i){return bt({render:o,defaultTagName:"span",ref:i,props:ye(r,{className:$(td.text,od.heading,od.p,td[t],n)})})});var ld=h(Q(),1),Ni="data-wp-hash";function Li(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&yg(document)),e.__wpStyleRuntime}function _g(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ni}]`))if(o.getAttribute(Ni)===t)return!0;return!1}function cd(e,t,o){if(!e.head)return;let n=Li(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(_g(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ni,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function yg(e){let t=Li();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function xg(e,t){let o=Li();o.styles.set(e,t);for(let n of o.documents.keys())cd(n,e,t)}typeof process>"u",xg("9db2873e7f","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._96e6251aad1a6136__badge{border-radius:var(--wpds-border-radius-lg,8px);padding-block:var(--wpds-dimension-padding-xs,4px);padding-inline:var(--wpds-dimension-padding-sm,8px)}._99f7158cb520f750__is-high-intent{background-color:var(--wpds-color-background-surface-error,#f6e6e3);color:var(--wpds-color-foreground-content-error,#470000)}.c20ebef2365bc8b7__is-medium-intent{background-color:var(--wpds-color-background-surface-warning,#fde6be);color:var(--wpds-color-foreground-content-warning,#2e1900)}._365e1626c6202e52__is-low-intent{background-color:var(--wpds-color-background-surface-caution,#fee995);color:var(--wpds-color-foreground-content-caution,#281d00)}._33f8198127ddf4ef__is-stable-intent{background-color:var(--wpds-color-background-surface-success,#c6f7cd);color:var(--wpds-color-foreground-content-success,#002900)}._04c1aca8fc449412__is-informational-intent{background-color:var(--wpds-color-background-surface-info,#deebfa);color:var(--wpds-color-foreground-content-info,#001b4f)}._90726e69d495ec19__is-draft-intent{background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);color:var(--wpds-color-foreground-content-neutral,#1e1e1e)}._898f4a544993bd39__is-none-intent{background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral,#dbdbdb);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);padding-block:calc(var(--wpds-dimension-padding-xs, 4px) - var(--wpds-border-width-xs, 1px));padding-inline:calc(var(--wpds-dimension-padding-sm, 8px) - var(--wpds-border-width-xs, 1px))}}}");var sd={badge:"_96e6251aad1a6136__badge","is-high-intent":"_99f7158cb520f750__is-high-intent","is-medium-intent":"c20ebef2365bc8b7__is-medium-intent","is-low-intent":"_365e1626c6202e52__is-low-intent","is-stable-intent":"_33f8198127ddf4ef__is-stable-intent","is-informational-intent":"_04c1aca8fc449412__is-informational-intent","is-draft-intent":"_90726e69d495ec19__is-draft-intent","is-none-intent":"_898f4a544993bd39__is-none-intent"},Ii=(0,ad.forwardRef)(function({intent:t="none",className:o,...n},r){return(0,ld.jsx)(Je,{ref:r,className:$(sd.badge,sd[`is-${t}-intent`],o),...n,variant:"body-sm"})});var rr=h(de(),1),dd=h(Ot(),1),fd=h(Q(),1);import{speak as Rg}from"@wordpress/a11y";var Mi="data-wp-hash";function Bi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Eg(document)),e.__wpStyleRuntime}function Sg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Mi}]`))if(o.getAttribute(Mi)===t)return!0;return!1}function ud(e,t,o){if(!e.head)return;let n=Bi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Sg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Mi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Eg(e){let t=Bi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)ud(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function ir(e,t){let o=Bi();o.styles.set(e,t);for(let n of o.documents.keys())ud(n,e,t)}typeof process>"u",ir("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Jo={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"};typeof process>"u",ir("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Tg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",ir("da99a163ac","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}");var kg={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active"};typeof process>"u",ir("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var Pg={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},pd=(0,rr.forwardRef)(function({tone:t="brand",variant:o="solid",size:n="default",className:r,focusableWhenDisabled:i=!0,disabled:s,loading:a,loadingAnnouncement:d=(0,dd.__)("Loading"),children:c,...l},f){let p=$(Pg.button,Tg["box-sizing"],kg["outset-ring--focus-except-active"],o!=="unstyled"&&Jo.button,Jo[`is-${t}`],Jo[`is-${o}`],Jo[`is-${n}`],a&&Jo["is-loading"],r);return(0,rr.useEffect)(()=>{a&&d&&Rg(d)},[a,d]),(0,fd.jsx)(_i,{ref:f,className:p,focusableWhenDisabled:i,disabled:s??a,...l,children:c})});var wd=h(de(),1);var gd=h(de(),1),bd=h($t(),1),hd=h(Q(),1),eo=(0,gd.forwardRef)(function({icon:t,size:o=24,...n},r){return(0,hd.jsx)(bd.SVG,{ref:r,...t.props,...n,width:o,height:o})});var _d=h(Q(),1),Hi="data-wp-hash";function zi(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ag(document)),e.__wpStyleRuntime}function Cg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Hi}]`))if(o.getAttribute(Hi)===t)return!0;return!1}function vd(e,t,o){if(!e.head)return;let n=zi(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Cg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Hi,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ag(e){let t=zi();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)vd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Og(e,t){let o=zi();o.styles.set(e,t);for(let n of o.documents.keys())vd(n,e,t)}typeof process>"u",Og("b74f1ac304",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._97b0fc33c028be1a__button,.abbb272e2ce49bd6__is-unstyled{appearance:none;padding:0}._97b0fc33c028be1a__button{--wp-ui-button-font-weight:var(--wpds-typography-font-weight-emphasis,600);--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-strong,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-strong-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 93%,#000));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand-strong,#fff);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-strong-active,#fff);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-strong-disabled,#8d8d8d);--wp-ui-button-padding-block:var(--wpds-dimension-padding-xs,4px);--wp-ui-button-padding-inline:var(--wpds-dimension-padding-md,12px);--wp-ui-button-height:var(--wpds-dimension-size-lg,40px);--wp-ui-button-aspect-ratio:auto;--wp-ui-button-font-size:var(--wpds-typography-font-size-md,13px);--wp-ui-button-min-width:calc(4ch + var(--wp-ui-button-padding-inline)*2);--wp-ui-button-icon-margin:calc((var(--wpds-dimension-size-2xs, 16px) - var(--wpds-dimension-size-sm, 24px))/2);--wp-ui-button-border-color:var(--wp-ui-button-background-color);--wp-ui-button-border-color-active:var(--wp-ui-button-background-color-active);--wp-ui-button-border-color-disabled:var(--wp-ui-button-background-color-disabled);--_gcd-button-font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);--_gcd-button-font-size:var(--wp-ui-button-font-size);--_gcd-button-font-weight:var(--wp-ui-button-font-weight);align-items:center;aspect-ratio:var(--wp-ui-button-aspect-ratio);background-clip:border-box;background-color:var(--wp-ui-button-background-color);border-color:var(--wp-ui-button-border-color);border-radius:var(--wpds-border-radius-sm,2px);border-style:solid;border-width:1px;color:var(--wp-ui-button-foreground-color);display:inline-flex;font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wp-ui-button-font-size);font-weight:var(--wp-ui-button-font-weight);gap:var(--wpds-dimension-gap-sm,8px);justify-content:center;line-height:var(--wpds-typography-line-height-sm,20px);max-width:100%;min-height:var(--wp-ui-button-height);min-width:var(--wp-ui-button-min-width);overflow-wrap:anywhere;padding-block:var(--wp-ui-button-padding-block);padding-inline:var(--wp-ui-button-padding-inline);position:relative;text-align:center;text-decoration:none;&:not([data-disabled]){cursor:var(--wpds-cursor-control,pointer)}@media not (prefers-reduced-motion){transition:color .1s ease-out;*{transition:opacity .1s ease-out}}&[href]{cursor:pointer}[href]{color:inherit;text-decoration:inherit}&:not([data-disabled]):is(:hover,:active,:focus){background-color:var(--wp-ui-button-background-color-active);border-color:var(--wp-ui-button-border-color-active);color:var(--wp-ui-button-foreground-color-active)}&[data-disabled]:not(._914b42f315c0e580__is-loading){background-color:var(--wp-ui-button-background-color-disabled);border-color:var(--wp-ui-button-border-color-disabled);color:var(--wp-ui-button-foreground-color-disabled);@media (forced-colors:active){border-bottom-color:GrayText;border-left-color:GrayText;border-right-color:GrayText;border-top-color:GrayText;color:GrayText}}&:before{aspect-ratio:1;border:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid;border-block-end-color:transparent;border-block-start-color:var(--wp-ui-button-foreground-color);border-inline-end-color:var(--wp-ui-button-foreground-color);border-inline-start-color:transparent;border-radius:50%;box-sizing:border-box;content:"";display:block;height:var(--wp-ui-button-font-size);inset-inline-start:50%;opacity:0;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);@media not (prefers-reduced-motion){transition:opacity .1s ease-out}@media (forced-colors:active){border-block-end-style:none;border-bottom-color:ButtonText;border-inline-start-style:none;border-left-color:ButtonText;border-right-color:ButtonText;border-top-color:ButtonText}}}._908205475f9f2a92__is-small{--wp-ui-button-padding-block:0px;--wp-ui-button-padding-inline:var(--wpds-dimension-padding-sm,8px);--wp-ui-button-height:var(--wpds-dimension-size-sm,24px)}._9f6fc6553aeb36fe__icon{margin:var(--wp-ui-button-icon-margin)}.dd460c965226cc77__is-brand{&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-brand-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-brand,var(--wp-admin-theme-color,#3858e9));--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 85%,#000));--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-brand-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal{--wp-ui-button-background-color:var(--wpds-color-background-interactive-brand-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-brand-weak-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 12%,#fff));--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-brand-weak-disabled,#0000)}}.e722a8f96726aa99__is-neutral{&.ad0619a3217c6a5b__is-minimal[aria-pressed=true],&.b50b3358c5fb4d0b__is-solid{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-strong,#2d2d2d);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-strong-active,#1e1e1e);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-strong-disabled,#e6e6e6);--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral-strong,#f0f0f0);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-strong-active,#f0f0f0);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-strong-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline,&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-foreground-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);--wp-ui-button-foreground-color-active:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);--wp-ui-button-foreground-color-disabled:var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d)}&._62d5a778b7b258ee__is-outline{--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000);--wp-ui-button-border-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d);--wp-ui-button-border-color-active:var(--wpds-color-stroke-interactive-neutral-active,#6e6e6e);--wp-ui-button-border-color-disabled:var(--wpds-color-stroke-interactive-neutral-disabled,#dbdbdb)}&.ad0619a3217c6a5b__is-minimal:not([aria-pressed=true]){--wp-ui-button-background-color:var(--wpds-color-background-interactive-neutral-weak,#0000);--wp-ui-button-background-color-active:var(--wpds-color-background-interactive-neutral-weak-active,#ededed);--wp-ui-button-background-color-disabled:var(--wpds-color-background-interactive-neutral-weak-disabled,#0000)}}.abbb272e2ce49bd6__is-unstyled{background:none;border:none;min-width:unset}.cf59cf1b69629838__is-compact{--wp-ui-button-height:var(--wpds-dimension-size-md,32px)}._914b42f315c0e580__is-loading:not(.abbb272e2ce49bd6__is-unstyled){color:transparent;&:not([data-disabled]):is(:hover,:active,:focus){color:transparent}@media (forced-colors:active){color:ButtonFace}*{opacity:0}&:before{opacity:1;transition-delay:.05s;@media not (prefers-reduced-motion){animation:_5a1d53da6f830c8d__loading-animation 1s linear infinite}}}}@keyframes _5a1d53da6f830c8d__loading-animation{0%{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(1turn)}}}');var Ng={button:"_97b0fc33c028be1a__button","is-unstyled":"abbb272e2ce49bd6__is-unstyled","is-loading":"_914b42f315c0e580__is-loading","is-small":"_908205475f9f2a92__is-small",icon:"_9f6fc6553aeb36fe__icon","is-brand":"dd460c965226cc77__is-brand","is-outline":"_62d5a778b7b258ee__is-outline","is-minimal":"ad0619a3217c6a5b__is-minimal","is-neutral":"e722a8f96726aa99__is-neutral","is-solid":"b50b3358c5fb4d0b__is-solid","is-compact":"cf59cf1b69629838__is-compact","loading-animation":"_5a1d53da6f830c8d__loading-animation"},Di=(0,wd.forwardRef)(function({className:t,icon:o,...n},r){return(0,_d.jsx)(eo,{ref:r,icon:o,className:$(Ng.icon,t),size:24,...n})});Di.displayName="Button.Icon";var sr=Object.assign(pd,{Icon:Di});var ar=h($t(),1),ji=h(Q(),1),Fi=(0,ji.jsx)(ar.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,ji.jsx)(ar.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 12v-1.5h1.5V16h-1.5Zm0-8v5h1.5V8h-1.5Z"})});var cr=h($t(),1),Vi=h(Q(),1),Wi=(0,Vi.jsx)(cr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Vi.jsx)(cr.Path,{d:"M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"})});var lr=h($t(),1),Yi=h(Q(),1),Ui=(0,Yi.jsx)(lr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Yi.jsx)(lr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12.218 5.377a.25.25 0 0 0-.436 0l-7.29 12.96a.25.25 0 0 0 .218.373h14.58a.25.25 0 0 0 .218-.372l-7.29-12.96Zm-1.743-.735c.669-1.19 2.381-1.19 3.05 0l7.29 12.96a1.75 1.75 0 0 1-1.525 2.608H4.71a1.75 1.75 0 0 1-1.525-2.608l7.29-12.96ZM12.75 17.46h-1.5v-1.5h1.5v1.5Zm-1.5-3h1.5v-5h-1.5v5Z"})});var dr=h($t(),1),Gi=h(Q(),1),Xi=(0,Gi.jsx)(dr.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Gi.jsx)(dr.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4v1.5h-1.5V8h1.5Zm0 8v-5h-1.5v5h1.5Z"})});var ur=h($t(),1),Ki=h(Q(),1),qi=(0,Ki.jsx)(ur.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",children:(0,Ki.jsx)(ur.Path,{fillRule:"evenodd",clipRule:"evenodd",d:"M12 18.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13ZM4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm11.53-1.47-1.06-1.06L11 12.94l-1.47-1.47-1.06 1.06L11 15.06l4.53-4.53Z"})});var yd=h(de(),1);function Zi(e,t,o){return(0,yd.cloneElement)(e??t,{children:o})}var Lg=h(Rd(),1);var Ed=h(Qi(),1),{lock:h4,unlock:Td}=(0,Ed.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/ui");function Ig(){let e=Lg;if(e.ThemeProvider)return e.ThemeProvider;if(!e.privateApis)throw new Error("@wordpress/ui: @wordpress/theme must expose `ThemeProvider` or `privateApis.ThemeProvider`.");return Td(e.privateApis).ThemeProvider}var kd=Ig();var Pd=h(de(),1),Ji="data-wp-hash";function $i(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Bg(document)),e.__wpStyleRuntime}function Mg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ji}]`))if(o.getAttribute(Ji)===t)return!0;return!1}function Cd(e,t,o){if(!e.head)return;let n=$i(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Mg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ji,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Bg(e){let t=$i();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Cd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Hg(e,t){let o=$i();o.styles.set(e,t);for(let n of o.documents.keys())Cd(n,e,t)}typeof process>"u",Hg("32aba35fe1","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._19ce0419607e1896__stack{display:flex}}}");var zg={stack:"_19ce0419607e1896__stack"},Dg={xs:"var(--wpds-dimension-gap-xs, 4px)",sm:"var(--wpds-dimension-gap-sm, 8px)",md:"var(--wpds-dimension-gap-md, 12px)",lg:"var(--wpds-dimension-gap-lg, 16px)",xl:"var(--wpds-dimension-gap-xl, 24px)","2xl":"var(--wpds-dimension-gap-2xl, 32px)","3xl":"var(--wpds-dimension-gap-3xl, 40px)"},Po=(0,Pd.forwardRef)(function({direction:t,gap:o,align:n,justify:r,wrap:i,render:s,...a},d){let c={gap:o&&Dg[o],alignItems:n,justifyContent:r,flexDirection:t,flexWrap:i};return bt({render:s,ref:d,props:ye(a,{style:c,className:zg.stack})})});var Kd=h(de(),1);var Vd=h(de(),1);var Id=h(de(),1);var ts="data-wp-hash";function os(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Fg(document)),e.__wpStyleRuntime}function jg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ts}]`))if(o.getAttribute(ts)===t)return!0;return!1}function Od(e,t,o){if(!e.head)return;let n=os(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(jg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Fg(e){let t=os();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Od(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Vg(e,t){let o=os();o.styles.set(e,t);for(let n of o.documents.keys())Od(n,e,t)}typeof process>"u",Vg("be37f31c1e","._11fc52b637ff8a7e__slot{inset:0;isolation:isolate;pointer-events:none;position:fixed;z-index:1000000003}@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._11fc52b637ff8a7e__slot>*{pointer-events:auto}}}");var Ad={slot:"_11fc52b637ff8a7e__slot"},Nd="data-wp-compat-overlay-slot";function Wg(){return typeof document>"u"?null:document}function Yg(){let e;try{e=window.top?.wp}catch{}let t=e??window.wp;return typeof t?.components=="object"&&t.components!==null}var ht=null;function es(e){return e.setAttribute("aria-hidden","false"),e}function Ug(e){let t=e.createElement("div");return t.setAttribute(Nd,""),Ad.slot&&t.classList.add(Ad.slot),e.body.appendChild(t),t}function Ld(){if(typeof window>"u"||!Yg()&&window.__wpUiCompatOverlaySlotEnabled!==!0)return;let e=Wg();if(!e||!e.body)return;if(ht&&ht.ownerDocument===e&&ht.isConnected)return es(ht);let t=e.querySelector(`[${Nd}]`);return t instanceof HTMLDivElement?(ht=es(t),ht):(ht?.isConnected&&ht.remove(),ht=es(Ug(e)),ht)}var Md=h(Q(),1),Bd=(0,Id.forwardRef)(function({container:t,...o},n){return(0,Md.jsx)(Qe.Portal,{container:t??Ld(),...o,ref:n})});var Hd=h(de(),1),jd=h(Q(),1),ns="data-wp-hash";function rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Xg(document)),e.__wpStyleRuntime}function Gg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ns}]`))if(o.getAttribute(ns)===t)return!0;return!1}function zd(e,t,o){if(!e.head)return;let n=rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Gg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ns,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Xg(e){let t=rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)zd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Dd(e,t){let o=rs();o.styles.set(e,t);for(let n of o.documents.keys())zd(n,e,t)}typeof process>"u",Dd("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var Kg={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",Dd("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var qg={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},Fd=(0,Hd.forwardRef)(function({align:t="center",className:o,side:n="top",sideOffset:r=4,...i},s){return(0,jd.jsx)(Qe.Positioner,{ref:s,align:t,side:n,sideOffset:r,...i,className:$(Kg["box-sizing"],qg.positioner,o)})});var $o=h(Q(),1),is="data-wp-hash";function ss(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Qg(document)),e.__wpStyleRuntime}function Zg(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${is}]`))if(o.getAttribute(is)===t)return!0;return!1}function Wd(e,t,o){if(!e.head)return;let n=ss(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Zg(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Qg(e){let t=ss();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Wd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Jg(e,t){let o=ss();o.styles.set(e,t);for(let n of o.documents.keys())Wd(n,e,t)}typeof process>"u",Jg("19fcc06039",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._480b748dd3510e64__positioner{z-index:var(--wp-ui-tooltip-z-index,initial)}._50096b232db7709d__popup{--_wp-ui-elevation-sm:0 1px 2px rgba(0,0,0,.05),0 2px 3px rgba(0,0,0,.04),0 6px 6px rgba(0,0,0,.03),0 8px 8px rgba(0,0,0,.02);background-color:var(--wpds-color-background-surface-neutral-strong,#fff);border-radius:var(--wpds-border-radius-md,4px);box-shadow:var(--_wp-ui-elevation-sm);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);font-family:var(--wpds-typography-font-family-body,-apple-system,system-ui,"Segoe UI","Roboto","Oxygen-Sans","Ubuntu","Cantarell","Helvetica Neue",sans-serif);font-size:var(--wpds-typography-font-size-sm,12px);line-height:1.4;padding:var(--wpds-dimension-padding-xs,4px) var(--wpds-dimension-padding-sm,8px);@media (forced-colors:active){border-bottom-color:CanvasText;border-bottom-style:solid;border-bottom-width:1px;border-left-color:CanvasText;border-left-style:solid;border-left-width:1px;border-right-color:CanvasText;border-right-style:solid;border-right-width:1px;border-top-color:CanvasText;border-top-style:solid;border-top-width:1px}}}}');var $g={positioner:"_480b748dd3510e64__positioner",popup:"_50096b232db7709d__popup"},eb={background:"#1e1e1e"},as=(0,Vd.forwardRef)(function({portal:t,positioner:o,children:n,className:r,...i},s){let a=(0,$o.jsx)(kd,{color:eb,children:(0,$o.jsx)(Qe.Popup,{ref:s,className:$($g.popup,r),...i,children:n})}),d=Zi(o,(0,$o.jsx)(Fd,{}),a);return Zi(t,(0,$o.jsx)(Bd,{}),d)});var Yd=h(de(),1),Ud=h(Q(),1),cs=(0,Yd.forwardRef)(function(t,o){return(0,Ud.jsx)(Qe.Trigger,{ref:o,...t})});var Gd=h(Q(),1);function ls(e){return(0,Gd.jsx)(Qe.Root,{...e})}var lt=h(Q(),1),ds="data-wp-hash";function us(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&nb(document)),e.__wpStyleRuntime}function ob(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ds}]`))if(o.getAttribute(ds)===t)return!0;return!1}function qd(e,t,o){if(!e.head)return;let n=us(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ob(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ds,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function nb(e){let t=us();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)qd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function rb(e,t){let o=us();o.styles.set(e,t);for(let n of o.documents.keys())qd(n,e,t)}typeof process>"u",rb("c5cdafb1bc","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer compositions{._28cfdc260e755391__icon-button{--wp-ui-button-aspect-ratio:1;--wp-ui-button-padding-inline:0px;--wp-ui-button-min-width:unset}.f1c70d719989a85a__icon{margin:-1px}}}");var Xd={"icon-button":"_28cfdc260e755391__icon-button",icon:"f1c70d719989a85a__icon"},fs=(0,Kd.forwardRef)(function({label:t,className:o,children:n,disabled:r,focusableWhenDisabled:i=!0,icon:s,size:a,shortcut:d,positioner:c,...l},f){let p=$(Xd["icon-button"],o);return(0,lt.jsxs)(ls,{children:[(0,lt.jsx)(cs,{ref:f,disabled:r&&!i,render:(0,lt.jsx)(sr,{...l,size:a,"aria-label":t,"aria-keyshortcuts":d?.ariaKeyShortcut,disabled:r,focusableWhenDisabled:i}),className:p,children:(0,lt.jsx)(eo,{icon:s,size:24,className:Xd.icon})}),(0,lt.jsxs)(as,{positioner:c,children:[t,d&&(0,lt.jsxs)(lt.Fragment,{children:[" ",(0,lt.jsx)("span",{"aria-hidden":"true",children:d.displayShortcut})]})]})]})});var Zd=h(de(),1),Qd=h(Ot(),1),Co=h(Q(),1),ps="data-wp-hash";function ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&sb(document)),e.__wpStyleRuntime}function ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ps}]`))if(o.getAttribute(ps)===t)return!0;return!1}function Jd(e,t,o){if(!e.head)return;let n=ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function sb(e){let t=ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Jd(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function pr(e,t){let o=ms();o.styles.set(e,t);for(let n of o.documents.keys())Jd(n,e,t)}typeof process>"u",pr("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var ab={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",pr("da99a163ac","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._08e8a2e44959f892__outset-ring--focus:focus,.c5cb3ee4bddaa8e4__outset-ring--focus-within-visible:focus-within:has(:focus-visible),.cd83dfc2126a0846__outset-ring--focus-within:focus-within,.d0541bc9dd9dc7b6__outset-ring--focus-visible:focus-visible,:focus-visible .ecadb9e080e2dfa5__outset-ring--focus-parent-visible{--_gcd-a-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));--_gcd-div-outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px)) solid var(--wpds-color-stroke-focus,var(--wp-admin-theme-color,#3858e9));outline-offset:var(--wpds-border-width-focus,var(--wp-admin-border-width-focus,2px))}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within,.e25b2bdd7aa21721__outset-ring--focus-except-active:focus{outline:none}._970d04df7376df67__outset-ring--focus-within-except-active:focus-within:not(:has(:active)),.e25b2bdd7aa21721__outset-ring--focus-except-active:focus:not(:active){@include mixins.focus-ring()}}}");var cb={"outset-ring--focus":"_08e8a2e44959f892__outset-ring--focus","outset-ring--focus-visible":"d0541bc9dd9dc7b6__outset-ring--focus-visible","outset-ring--focus-within":"cd83dfc2126a0846__outset-ring--focus-within","outset-ring--focus-within-visible":"c5cb3ee4bddaa8e4__outset-ring--focus-within-visible","outset-ring--focus-parent-visible":"ecadb9e080e2dfa5__outset-ring--focus-parent-visible","outset-ring--focus-except-active":"e25b2bdd7aa21721__outset-ring--focus-except-active","outset-ring--focus-within-except-active":"_970d04df7376df67__outset-ring--focus-within-except-active"};typeof process>"u",pr("e8e6a9be37",'@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{.d4250949359b05ce__link{text-decoration-thickness:from-font;text-underline-offset:.2em}.c6055659b8e2cd2c__is-brand,.c6055659b8e2cd2c__is-brand:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9));color:var(--wpds-color-foreground-interactive-brand,var(--wp-admin-theme-color,#3858e9))}.c6055659b8e2cd2c__is-brand:active,.c6055659b8e2cd2c__is-brand:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000));color:var(--wpds-color-foreground-interactive-brand-active,color-mix(in oklch,var(--wp-admin-theme-color,#3858e9) 52%,#000))}._92e0dfcaeee15b88__is-neutral,._92e0dfcaeee15b88__is-neutral:visited{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral,#1e1e1e);text-decoration-color:var(--wpds-color-stroke-interactive-neutral,#8d8d8d)}._92e0dfcaeee15b88__is-neutral:active,._92e0dfcaeee15b88__is-neutral:hover{--_gcd-a-color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e);color:var(--wpds-color-foreground-interactive-neutral-active,#1e1e1e)}.cf122a9bf1035d42__is-unstyled{--_gcd-a-color:inherit;color:inherit;text-decoration:none}._0cb411afac4c86c7__link-icon{display:inline-block;font-weight:var(--wpds-typography-font-weight-default,400);line-height:1;margin-inline-start:var(--wpds-dimension-padding-xs,4px);text-decoration:none}._0cb411afac4c86c7__link-icon:after{content:"\\2197"}._0cb411afac4c86c7__link-icon:dir(rtl):after{content:"\\2196"}}}');var fr={link:"d4250949359b05ce__link","is-brand":"c6055659b8e2cd2c__is-brand","is-neutral":"_92e0dfcaeee15b88__is-neutral","is-unstyled":"cf122a9bf1035d42__is-unstyled","link-icon":"_0cb411afac4c86c7__link-icon"};typeof process>"u",pr("af6d9984a6","._6defc79820e382c6__button{box-sizing:var(--_gcd-button-box-sizing,border-box);font-family:var(--_gcd-button-font-family,inherit);font-size:var(--_gcd-button-font-size,inherit);font-weight:var(--_gcd-button-font-weight,inherit)}.d2cff2e5dea83bd1__input{box-sizing:var(--_gcd-input-box-sizing,border-box);font-family:var(--_gcd-input-font-family,inherit);font-size:var(--_gcd-input-font-size,inherit);font-weight:var(--_gcd-input-font-weight,inherit);margin:var(--_gcd-input-margin,0);&:is(textarea,[type=text],[type=password],[type=color],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){background-color:var(--_gcd-input-background-color,transparent);border:var(--_gcd-input-border,none);border-radius:var(--_gcd-input-border-radius,0);box-shadow:var(--_gcd-input-box-shadow,0 0 0 transparent);color:var(--_gcd-input-color,var(--wpds-color-foreground-interactive-neutral,#1e1e1e));&:focus{border-color:var(--_gcd-input-border-color-focus,var(--wp-admin-theme-color));box-shadow:var(--_gcd-input-box-shadow-focus,none);outline:var(--_gcd-input-outline-focus,none)}&:disabled{background:var(--_gcd-input-background-disabled,transparent);border-color:var(--_gcd-input-border-color-disabled,transparent);box-shadow:var(--_gcd-input-box-shadow-disabled,none);color:var(--_gcd-input-color-disabled,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}&::placeholder{color:var(--_gcd-input-placeholder-color,var(--wpds-color-foreground-interactive-neutral-disabled,#8d8d8d))}}&:is(textarea,[type=text],[type=password],[type=date],[type=datetime],[type=datetime-local],[type=email],[type=month],[type=number],[type=search],[type=tel],[type=time],[type=url],[type=week]){line-height:var(--_gcd-input-line-height,inherit);min-height:var(--_gcd-input-min-height,auto);padding:var(--_gcd-input-padding,0)}}._547d86373d02e108__textarea{box-sizing:var(--_gcd-textarea-box-sizing,border-box);overflow:var(--_gcd-textarea-overflow,auto);resize:var(--_gcd-textarea-resize,block)}._8c15fd0ed9f28ba4__div{outline:var(--_gcd-div-outline,0 solid transparent)}p._43cec3e1eec1066d__p{font-size:var(--_gcd-p-font-size,13px);line-height:var(--_gcd-p-line-height,1.5);margin:var(--_gcd-p-margin,0)}:is(h1,h2,h3,h4,h5,h6).e97669c6d9a38497__heading{color:var(--_gcd-heading-color,var(--wpds-color-foreground-content-neutral,#1e1e1e));font-size:var(--_gcd-heading-font-size,inherit);font-weight:var(--_gcd-heading-font-weight,var(--wpds-typography-font-weight-emphasis,600));margin:var(--_gcd-heading-margin,0)}._2c0831b0499dbd6e__a,._2c0831b0499dbd6e__a:is(:hover,:focus,:active){border-radius:var(--_gcd-a-border-radius,0);box-shadow:var(--_gcd-a-box-shadow,none);color:var(--_gcd-a-color,inherit);outline:var(--_gcd-a-outline,0 solid transparent);transition:var(--_gcd-a-transition,none)}");var lb={button:"_6defc79820e382c6__button",input:"d2cff2e5dea83bd1__input",textarea:"_547d86373d02e108__textarea",div:"_8c15fd0ed9f28ba4__div",p:"_43cec3e1eec1066d__p",heading:"e97669c6d9a38497__heading",a:"_2c0831b0499dbd6e__a"},en=(0,Zd.forwardRef)(function({children:t,variant:o="default",tone:n="brand",openInNewTab:r=!1,render:i,className:s,...a},d){return bt({render:i,defaultTagName:"a",ref:d,props:ye(a,{className:$(lb.a,ab["box-sizing"],cb["outset-ring--focus-except-active"],o!=="unstyled"&&fr.link,o!=="unstyled"&&fr[`is-${n}`],o==="unstyled"&&fr["is-unstyled"],s),target:r?"_blank":void 0,children:(0,Co.jsxs)(Co.Fragment,{children:[t,r&&(0,Co.jsx)("span",{className:fr["link-icon"],role:"img","aria-label":(0,Qd.__)("(opens in a new tab)")})]})})})});var tn={};At(tn,{ActionButton:()=>xu,ActionLink:()=>Eu,Actions:()=>fu,CloseIcon:()=>hu,Description:()=>lu,Root:()=>tu,Title:()=>iu});var Ao=h(de(),1);import{speak as db}from"@wordpress/a11y";var Oo=h(Q(),1),bs="data-wp-hash";function hs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&fb(document)),e.__wpStyleRuntime}function ub(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${bs}]`))if(o.getAttribute(bs)===t)return!0;return!1}function $d(e,t,o){if(!e.head)return;let n=hs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(ub(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(bs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function fb(e){let t=hs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)$d(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function eu(e,t){let o=hs();o.styles.set(e,t);for(let n of o.documents.keys())$d(n,e,t)}typeof process>"u",eu("10f3806643","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer utilities{._336cd3e4e743482f__box-sizing{box-sizing:border-box;*,:after,:before{box-sizing:inherit}}}}");var pb={"box-sizing":"_336cd3e4e743482f__box-sizing"};typeof process>"u",eu("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var gs={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},mb={neutral:null,info:Xi,warning:Fi,success:qi,error:Ui};function gb(e){return e==="error"?"assertive":"polite"}function bb(e){if(e){if(typeof e=="string")return e;try{return(0,Ao.renderToString)(e)}catch{return}}}function hb(e,t){let o=bb(e);(0,Ao.useEffect)(()=>{o&&db(o,t)},[o,t])}var tu=(0,Ao.forwardRef)(function({intent:t="neutral",children:o,icon:n,spokenMessage:r=o,politeness:i=gb(t),render:s,...a},d){hb(r,i);let c=n===null?null:n??mb[t],l=$(gs.notice,gs[`is-${t}`],pb["box-sizing"]);return bt({defaultTagName:"div",render:s,ref:d,props:ye({className:l,children:(0,Oo.jsxs)(Oo.Fragment,{children:[o,c&&(0,Oo.jsx)(eo,{className:gs.icon,icon:c})]})},a)})});var ou=h(de(),1);var ru=h(Q(),1),ws="data-wp-hash";function vs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&vb(document)),e.__wpStyleRuntime}function wb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${ws}]`))if(o.getAttribute(ws)===t)return!0;return!1}function nu(e,t,o){if(!e.head)return;let n=vs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(wb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(ws,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function vb(e){let t=vs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)nu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function _b(e,t){let o=vs();o.styles.set(e,t);for(let n of o.documents.keys())nu(n,e,t)}typeof process>"u",_b("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var yb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},iu=(0,ou.forwardRef)(function({className:t,...o},n){return(0,ru.jsx)(Je,{ref:n,variant:"heading-md",className:$(yb.title,t),...o})});var su=h(de(),1);var cu=h(Q(),1),_s="data-wp-hash";function ys(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Rb(document)),e.__wpStyleRuntime}function xb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${_s}]`))if(o.getAttribute(_s)===t)return!0;return!1}function au(e,t,o){if(!e.head)return;let n=ys(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(xb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(_s,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Rb(e){let t=ys();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)au(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Sb(e,t){let o=ys();o.styles.set(e,t);for(let n of o.documents.keys())au(n,e,t)}typeof process>"u",Sb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Eb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},lu=(0,su.forwardRef)(function({className:t,...o},n){return(0,cu.jsx)(Je,{ref:n,variant:"body-md",className:$(Eb.description,t),...o})});var du=h(de(),1);var xs="data-wp-hash";function Rs(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&kb(document)),e.__wpStyleRuntime}function Tb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${xs}]`))if(o.getAttribute(xs)===t)return!0;return!1}function uu(e,t,o){if(!e.head)return;let n=Rs(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Tb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(xs,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function kb(e){let t=Rs();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)uu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Pb(e,t){let o=Rs();o.styles.set(e,t);for(let n of o.documents.keys())uu(n,e,t)}typeof process>"u",Pb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Cb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},fu=(0,du.forwardRef)(function({render:t,...o},n){return bt({defaultTagName:"div",render:t,ref:n,props:ye({className:Cb.actions},o)})});var pu=h(de(),1),mu=h(Ot(),1);var bu=h(Q(),1),Ss="data-wp-hash";function Es(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ob(document)),e.__wpStyleRuntime}function Ab(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ss}]`))if(o.getAttribute(Ss)===t)return!0;return!1}function gu(e,t,o){if(!e.head)return;let n=Es(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ab(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ss,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ob(e){let t=Es();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)gu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Nb(e,t){let o=Es();o.styles.set(e,t);for(let n of o.documents.keys())gu(n,e,t)}typeof process>"u",Nb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var Lb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},hu=(0,pu.forwardRef)(function({className:t,icon:o=Wi,label:n=(0,mu.__)("Dismiss"),...r},i){return(0,bu.jsx)(fs,{...r,ref:i,className:$(Lb["close-icon"],t),variant:"minimal",size:"small",tone:"neutral",icon:o,label:n})});var vu=h(de(),1);var yu=h(Q(),1),Ts="data-wp-hash";function ks(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Mb(document)),e.__wpStyleRuntime}function Ib(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ts}]`))if(o.getAttribute(Ts)===t)return!0;return!1}function _u(e,t,o){if(!e.head)return;let n=ks(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Ib(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ts,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Mb(e){let t=ks();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)_u(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Bb(e,t){let o=ks();o.styles.set(e,t);for(let n of o.documents.keys())_u(n,e,t)}typeof process>"u",Bb("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var wu={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},xu=(0,vu.forwardRef)(function({className:t,loading:o,loadingAnnouncement:n,variant:r,...i},s){return(0,yu.jsx)(sr,{...i,...o!==void 0?{loading:o,loadingAnnouncement:n??""}:{},ref:s,size:"compact",tone:"neutral",variant:r,className:$(wu["action-button"],wu[`is-action-button-${r}`],t)})});var Ru=h(de(),1);var Cs=h(Q(),1),Ps="data-wp-hash";function As(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&zb(document)),e.__wpStyleRuntime}function Hb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Ps}]`))if(o.getAttribute(Ps)===t)return!0;return!1}function Su(e,t,o){if(!e.head)return;let n=As(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Hb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Ps,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function zb(e){let t=As();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Su(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Db(e,t){let o=As();o.styles.set(e,t);for(let n of o.documents.keys())Su(n,e,t)}typeof process>"u",Db("726c480820","@layer wp-ui{@layer utilities, components, compositions, overrides;@layer components{._4145abab73d17514__notice{--icon-height:var(--wpds-dimension-size-sm,24px);--text-vertical-padding:calc((var(--icon-height) - var(--wpds-typography-line-height-sm, 20px))/2);--wp-ui-notice-background-color:var(--wpds-color-background-surface-neutral-weak,#f4f4f4);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-neutral,#dbdbdb);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-neutral,#1e1e1e);align-items:start;background-color:var(--wp-ui-notice-background-color);border:1px solid var(--wp-ui-notice-border-color);border-radius:var(--wpds-border-radius-lg,8px);container-type:inline-size;display:grid;grid-template-columns:auto 1fr auto;padding:var(--wpds-dimension-padding-md,12px)}.d0a25570cb528528__icon{color:var(--wp-ui-notice-decorative-icon-color);grid-column:1;grid-row:1;margin-inline-end:var(--wpds-dimension-gap-xs,4px)}._1904b570a89bb815__description,.b5397fb9d05389e3__title{color:var(--wp-ui-notice-text-color);grid-column:2;padding-block:var(--text-vertical-padding)}._1904b570a89bb815__description{text-wrap:pretty}._0a1270dcdd79c031__actions{display:flex;flex-wrap:wrap;gap:var(--wpds-dimension-gap-md,12px);grid-column:2}._4145abab73d17514__notice:has(._1904b570a89bb815__description) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions{margin-block-start:var(--wpds-dimension-gap-sm,8px)}._983740ab855c4e09__action-button{flex-shrink:0}.d329e7416d368d31__action-link{flex-shrink:0;&:not(:first-child){margin-inline-start:var(--wpds-dimension-gap-xs,4px)}&:not(:last-child){margin-inline-end:var(--wpds-dimension-gap-xs,4px)}}._487e6a5c1375f7dc__close-icon{grid-column:3;grid-row:1;margin-inline-start:var(--wpds-dimension-gap-xs,4px)}._531c140826094795__is-info{--wp-ui-notice-background-color:var(--wpds-color-background-surface-info-weak,#f3f9ff);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-info,#a9c6e7);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-info,#001b4f);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-info-weak,#006bd7)}.ae2e1004697cce95__is-warning{--wp-ui-notice-background-color:var(--wpds-color-background-surface-warning-weak,#fff7e1);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-warning,#e1bc7c);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-warning,#2e1900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-warning-weak,#926300)}._2e614a76af494837__is-success{--wp-ui-notice-background-color:var(--wpds-color-background-surface-success-weak,#ebffed);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-success,#94d29e);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-success,#002900);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-success-weak,#008030)}.af00331ae17a0065__is-error{--wp-ui-notice-background-color:var(--wpds-color-background-surface-error-weak,#fff6f5);--wp-ui-notice-border-color:var(--wpds-color-stroke-surface-error,#dab1aa);--wp-ui-notice-text-color:var(--wpds-color-foreground-content-error,#470000);--wp-ui-notice-decorative-icon-color:var(--wpds-color-foreground-content-error-weak,#cc1818)}@container (max-width: 320px){._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._0a1270dcdd79c031__actions,._4145abab73d17514__notice:has(.b5397fb9d05389e3__title) ._1904b570a89bb815__description{grid-column:1/3}}}@layer compositions{.d329e7416d368d31__action-link{margin-block:auto}._487e6a5c1375f7dc__close-icon,._983740ab855c4e09__action-button:is(._8ddb8fb33fbf3d38__is-action-button-outline,._77bbde495a8a0af3__is-action-button-minimal){--wp-ui-button-background-color-active:color-mix(in srgb,transparent 50%,var(--wpds-color-background-interactive-neutral-weak-active,#ededed))}}}");var jb={notice:"_4145abab73d17514__notice",icon:"d0a25570cb528528__icon",title:"b5397fb9d05389e3__title",description:"_1904b570a89bb815__description",actions:"_0a1270dcdd79c031__actions","action-button":"_983740ab855c4e09__action-button","action-link":"d329e7416d368d31__action-link","close-icon":"_487e6a5c1375f7dc__close-icon","is-info":"_531c140826094795__is-info","is-warning":"ae2e1004697cce95__is-warning","is-success":"_2e614a76af494837__is-success","is-error":"af00331ae17a0065__is-error","is-action-button-outline":"_8ddb8fb33fbf3d38__is-action-button-outline","is-action-button-minimal":"_77bbde495a8a0af3__is-action-button-minimal"},Eu=(0,Ru.forwardRef)(function({className:t,render:o,...n},r){return(0,Cs.jsx)(Je,{ref:r,className:$(jb["action-link"],t),...n,variant:"body-md",render:(0,Cs.jsx)(en,{tone:"neutral",variant:"default",render:o})})});var Tu=h(de(),1),ku=h(Q(),1),Pu=(0,Tu.forwardRef)(({children:e,className:t,ariaLabel:o,as:n="div",...r},i)=>(0,ku.jsx)(n,{ref:i,className:$("admin-ui-navigable-region",t),"aria-label":o,role:"region",tabIndex:"-1",...r,children:e}));Pu.displayName="NavigableRegion";var Cu=Pu;var Ou=h(on(),1),{Fill:Nu,Slot:Lu}=(0,Ou.createSlotFill)("SidebarToggle");var $e=h(Q(),1),Os="data-wp-hash";function Ns(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Vb(document)),e.__wpStyleRuntime}function Fb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Os}]`))if(o.getAttribute(Os)===t)return!0;return!1}function Iu(e,t,o){if(!e.head)return;let n=Ns(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Fb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Os,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Vb(e){let t=Ns();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Iu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Wb(e,t){let o=Ns();o.styles.set(e,t);for(let n of o.documents.keys())Iu(n,e,t)}typeof process>"u",Wb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var to={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Mu({headingLevel:e=1,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:s,showSidebarToggle:a=!0}){let d=`h${e}`;return(0,$e.jsxs)(Po,{direction:"column",className:to.header,children:[(0,$e.jsxs)(Po,{className:to["header-content"],direction:"row",gap:"sm",justify:"space-between",children:[(0,$e.jsxs)(Po,{direction:"row",gap:"sm",align:"center",justify:"start",children:[a&&(0,$e.jsx)(Lu,{bubblesVirtually:!0,className:to["sidebar-toggle-slot"]}),n&&(0,$e.jsx)("div",{className:to["header-visual"],"aria-hidden":"true",children:n}),r&&(0,$e.jsx)(Je,{className:to["header-title"],render:(0,$e.jsx)(d,{}),variant:"heading-lg",children:r}),t,o]}),s&&(0,$e.jsx)(Po,{align:"center",className:to["header-actions"],direction:"row",gap:"sm",children:s})]}),i&&(0,$e.jsx)(Je,{render:(0,$e.jsx)("p",{}),variant:"body-md",className:to["header-subtitle"],children:i})]})}var nn=h(Q(),1),Is="data-wp-hash";function Ms(){let e=globalThis;return e.__wpStyleRuntime||(e.__wpStyleRuntime={documents:new Map,styles:new Map,injectedStyles:new WeakMap},typeof document<"u"&&Ub(document)),e.__wpStyleRuntime}function Yb(e,t){if(!e.head)return!1;for(let o of e.head.querySelectorAll(`style[${Is}]`))if(o.getAttribute(Is)===t)return!0;return!1}function Bu(e,t,o){if(!e.head)return;let n=Ms(),r=n.injectedStyles.get(e);if(r||(r=new Set,n.injectedStyles.set(e,r)),r.has(t))return;if(Yb(e,t)){r.add(t);return}let i=e.createElement("style");i.setAttribute(Is,t),i.appendChild(e.createTextNode(o)),e.head.appendChild(i),r.add(t)}function Ub(e){let t=Ms();t.documents.set(e,(t.documents.get(e)??0)+1);for(let[o,n]of t.styles)Bu(e,o,n);return()=>{let o=t.documents.get(e);if(o!==void 0){if(o<=1){t.documents.delete(e);return}t.documents.set(e,o-1)}}}function Gb(e,t){let o=Ms();o.styles.set(e,t);for(let n of o.documents.keys())Bu(n,e,t)}typeof process>"u",Gb("ddd9aab364","._956b6df0898efed0__page{text-wrap:pretty;background-color:var(--wpds-color-background-surface-neutral,#fcfcfc);color:var(--wpds-color-foreground-content-neutral,#1e1e1e);display:flex;flex-flow:column;height:100%;position:relative;z-index:1}._0625b55e82a0d93d__header{background:var(--wpds-color-background-surface-neutral-strong,#fff);border-block-end:var(--wpds-border-width-xs,1px) solid var(--wpds-color-stroke-surface-neutral-weak,#f0f0f0);inset-block-start:0;padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px);position:sticky;z-index:1}.a43c44d5ae28b2e8__header-content{min-height:var(--wpds-dimension-size-md,32px)}.b7cb5b9daf3a3b25__header-actions{flex-shrink:0}._8113be94e7caf73c__header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._9a776c7f70996f61__header-visual{display:grid;flex-shrink:0;grid-template-columns:1fr;grid-template-rows:1fr;height:var(--wpds-dimension-size-sm,24px);width:var(--wpds-dimension-size-sm,24px);>*{grid-column:1/-1;grid-row:1/-1;max-height:100%;max-width:100%}}.d5e0920cd15d35bc__sidebar-toggle-slot:empty{display:none}._60fea2f6bf5319cd__header-subtitle{color:var(--wpds-color-foreground-content-neutral-weak,#707070);padding-block-end:var(--wpds-dimension-padding-xs,4px)}.be5e57d029ec4036__content{display:flex;flex-direction:column;flex-grow:1;overflow:auto;&._128806d0b26e3a50__has-padding{padding:var(--wpds-dimension-padding-lg,16px) var(--wpds-dimension-padding-2xl,24px)}}");var Ls={page:"_956b6df0898efed0__page",header:"_0625b55e82a0d93d__header","header-content":"a43c44d5ae28b2e8__header-content","header-actions":"b7cb5b9daf3a3b25__header-actions","header-title":"_8113be94e7caf73c__header-title","header-visual":"_9a776c7f70996f61__header-visual","sidebar-toggle-slot":"d5e0920cd15d35bc__sidebar-toggle-slot","header-subtitle":"_60fea2f6bf5319cd__header-subtitle",content:"be5e57d029ec4036__content","has-padding":"_128806d0b26e3a50__has-padding"};function Hu({headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,children:s,className:a,actions:d,ariaLabel:c,hasPadding:l=!1,showSidebarToggle:f=!0}){let p=$(Ls.page,a);return(0,nn.jsxs)(Cu,{className:p,ariaLabel:c??(typeof r=="string"?r:""),children:[(r||t||o||d||n)&&(0,nn.jsx)(Mu,{headingLevel:e,breadcrumbs:t,badges:o,visual:n,title:r,subTitle:i,actions:d,showSidebarToggle:f}),l?(0,nn.jsx)("div",{className:$(Ls.content,Ls["has-padding"]),children:s}):s]})}Hu.SidebarToggleFill=Nu;var Bs=Hu;var dt=h(on()),lf=h(rn()),df=h(de()),Tt=h(Ot()),uf=h(mr());import{privateApis as l0}from"@wordpress/connectors";var ju=h(Qi()),{lock:l3,unlock:No}=(0,ju.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/routes");if(typeof document<"u"&&!document.head.querySelector("style[data-wp-hash='09e9b056ea']")){let e=document.createElement("style");e.setAttribute("data-wp-hash","09e9b056ea"),e.appendChild(document.createTextNode(".connectors-page{box-sizing:border-box;margin:0 auto;max-width:680px;padding:24px;width:100%}.connectors-page .components-item{background:#fff;border:1px solid #ddd;border-radius:8px;overflow:hidden;padding:20px;scroll-margin-top:120px}.connectors-page .connector-settings__error{color:#cc1818}.connectors-page .connector-settings .components-text-control__input{font-family:monospace;scroll-margin-top:120px}.connectors-page__file-mods-notice{margin-bottom:16px}.connectors-page--empty{align-items:center;display:flex;flex-direction:column;flex-grow:1;gap:32px;justify-content:center;text-align:center}.connectors-page .ai-plugin-callout{background-color:#e7d4e4;background-image:radial-gradient(ellipse 70% 120% at 18% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 92% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 58% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%);border-radius:8px;overflow:hidden;padding:24px;padding-inline-end:150px;position:relative}[dir=rtl] .connectors-page .ai-plugin-callout{background-image:radial-gradient(ellipse 70% 120% at 82% 115%,rgba(202,158,198,.75) 0,rgba(202,158,198,0) 60%),radial-gradient(ellipse 55% 110% at 8% -15%,rgba(208,175,217,.7) 0,rgba(208,175,217,0) 65%),radial-gradient(ellipse 40% 85% at 42% -10%,rgba(170,130,184,.45) 0,rgba(170,130,184,0) 70%)}.connectors-page .ai-plugin-callout__content{align-items:flex-start;display:flex;flex-direction:column;gap:12px;padding-top:2px}.connectors-page .ai-plugin-callout__content p{font-size:13px;line-height:20px;margin:0}.connectors-page .ai-plugin-callout__decoration{height:110px;inset-inline-end:16px;position:absolute;top:12px;width:110px}.connectors-page>p{color:#949494}@media (max-width:680px){.connectors-page .ai-plugin-callout{padding:12px;padding-inline-end:100px}.connectors-page .ai-plugin-callout__decoration{height:75px;inset-inline-end:8px;top:8px;width:75px}}@media (max-width:480px){.connectors-page{padding:8px}.connectors-page .ai-plugin-callout{padding-inline-end:130px}.connectors-page .components-item{padding:12px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child svg{height:32px;width:32px}.connectors-page .components-item>.components-v-stack>.components-h-stack:first-child>.components-h-stack:last-child{align-items:flex-end;flex-direction:column}}")),document.head.appendChild(e)}var cn=h(on()),Ws=h(mr()),ln=h(rn()),wt=h(de()),Xe=h(Ot()),rf=h(Hs()),sf=h(Wu());var gr=h(on()),js=h(de()),Qu=h(rn()),oo=h(Ot());import{__experimentalRegisterConnector as Xb,__experimentalConnectorItem as Zu,__experimentalDefaultConnectorSettings as Kb,__experimentalApplicationPasswordConnectorSettings as qb,privateApis as Zb}from"@wordpress/connectors";var zs=h(mr()),an=h(rn()),sn=h(de()),fe=h(Ot()),Yu=h(Hs());function Ds({file:e,settingName:t,connectorName:o,isInstalled:n,isActivated:r,keySource:i="none",initialIsConnected:s=!1}){let[a,d]=(0,sn.useState)(!1),[c,l]=(0,sn.useState)(!1),[f,p]=(0,sn.useState)(s),[m,u]=(0,sn.useState)(null),g=e?.replace(/\.php$/,""),v=g?.includes("/")?g.split("/")[0]:g,{derivedPluginStatus:_,canManagePlugins:w,currentApiKey:y,currentUsername:b,hasStoredCredentials:S,hasResolvedSettings:x,canInstallPlugins:E}=(0,an.useSelect)(K=>{let J=K(zs.store),me=J.getEntityRecord("root","site")?.[t],le=typeof me=="string"?me:"",X=typeof me=="object"&&me!==null?me:void 0,pe=X!==void 0?!!X.username&&!!X.password:!!le,ue=J.hasFinishedResolution("getEntityRecord",["root","site"]),vt=!!J.canUser("create",{kind:"root",name:"plugin"}),Te={currentApiKey:le,currentUsername:X?.username??"",hasStoredCredentials:pe,hasResolvedSettings:ue,canInstallPlugins:vt};if(!e)return{...Te,derivedPluginStatus:ue?"active":"checking",canManagePlugins:void 0};let Ve=J.getEntityRecord("root","plugin",g);if(!J.hasFinishedResolution("getEntityRecord",["root","plugin",g]))return{...Te,derivedPluginStatus:"checking",canManagePlugins:void 0};if(Ve){let no=Ve.status==="active"||Ve.status==="network-active";return{...Te,derivedPluginStatus:no?"active":"inactive",canManagePlugins:!0}}let He="not-installed";return r?He="active":n&&(He="inactive"),{...Te,derivedPluginStatus:He,canManagePlugins:!1}},[e,g,t,n,r]),T=m??_,k=w,C=T==="active"&&f||m==="active"&&S,{saveEntityRecord:j,invalidateResolution:A}=(0,an.useDispatch)(zs.store),{createSuccessNotice:L,createErrorNotice:I}=(0,an.useDispatch)(Yu.store),R=K=>j("root","site",{[t]:K},{throwOnError:!0}),N=()=>{L((0,fe.sprintf)((0,fe.__)("%s connected successfully."),o),{id:"connector-connect-success",type:"snackbar"})},H=()=>{L((0,fe.sprintf)((0,fe.__)("%s disconnected."),o),{id:"connector-disconnect-success",type:"snackbar"})},P=()=>{I((0,fe.sprintf)((0,fe.__)("Failed to disconnect %s."),o),{id:"connector-disconnect-error",type:"snackbar"})},O=async()=>{if(v){l(!0);try{await j("root","plugin",{slug:v,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s installed and activated successfully."),o),{id:"connector-plugin-install-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to install plugin for %s."),o),{id:"connector-plugin-install-error",type:"snackbar"})}finally{l(!1)}}},M=async()=>{if(e){l(!0);try{await j("root","plugin",{plugin:g,status:"active"},{throwOnError:!0}),u("active"),A("getEntityRecord",["root","site"]),d(!0),L((0,fe.sprintf)((0,fe.__)("Plugin for %s activated successfully."),o),{id:"connector-plugin-activate-success",type:"snackbar"})}catch{I((0,fe.sprintf)((0,fe.__)("Failed to activate plugin for %s."),o),{id:"connector-plugin-activate-error",type:"snackbar"})}finally{l(!1)}}};return{pluginStatus:T,canInstallPlugins:E,canActivatePlugins:k,isExpanded:a,setIsExpanded:d,isBusy:c,isConnected:C,currentApiKey:y,currentUsername:b,hasResolvedSettings:x,keySource:i,handleButtonClick:()=>{if(T==="not-installed"){if(E===!1)return;O()}else if(T==="inactive"){if(k===!1)return;M()}else d(!a)},getButtonLabel:()=>{if(c)return T==="not-installed"?(0,fe.__)("Installing\u2026"):(0,fe.__)("Activating\u2026");if(a)return(0,fe.__)("Cancel");if(C)return(0,fe.__)("Edit");switch(T){case"checking":return(0,fe.__)("Checking\u2026");case"not-installed":return(0,fe.__)("Install");case"inactive":return(0,fe.__)("Activate");case"active":return(0,fe.__)("Set up")}},saveApiKey:async K=>{let J=y;try{let le=(await R(K))?.[t];if(K&&(le===J||!le))throw new Error("It was not possible to connect to the provider using this key.");p(!0),N()}catch(ne){throw console.error("Failed to save API key:",ne),ne}},removeApiKey:async()=>{try{await R(""),p(!1),H()}catch(K){console.error("Failed to remove API key:",K),P()}},saveCredentials:async({username:K,applicationPassword:J})=>{try{let le=(await R({username:K,password:J}))?.[t];if(!le?.username||!le?.password)throw new Error((0,fe.__)("It was not possible to save these credentials."));p(!0),N()}catch(ne){throw console.error("Failed to save credentials:",ne),ne}},removeCredentials:async()=>{try{await R({username:"",password:""}),p(!1),H()}catch(K){console.error("Failed to remove credentials:",K),P()}}}}var Uu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0201-1.1685a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4043-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z",fill:"currentColor"})),Gu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M6.2 21.024L12.416 17.536L12.52 17.232L12.416 17.064H12.112L11.072 17L7.52 16.904L4.44 16.776L1.456 16.616L0.704 16.456L0 15.528L0.072 15.064L0.704 14.64L1.608 14.72L3.608 14.856L6.608 15.064L8.784 15.192L12.008 15.528H12.52L12.592 15.32L12.416 15.192L12.28 15.064L9.176 12.96L5.816 10.736L4.056 9.456L3.104 8.808L2.624 8.2L2.416 6.872L3.28 5.92L4.44 6L4.736 6.08L5.912 6.984L8.424 8.928L11.704 11.344L12.184 11.744L12.376 11.608L12.4 11.512L12.184 11.152L10.4 7.928L8.496 4.648L7.648 3.288L7.424 2.472C7.344 2.136 7.288 1.856 7.288 1.512L8.272 0.176L8.816 0L10.128 0.176L10.68 0.656L11.496 2.52L12.816 5.456L14.864 9.448L15.464 10.632L15.784 11.728L15.904 12.064H16.112V11.872L16.28 9.624L16.592 6.864L16.896 3.312L17 2.312L17.496 1.112L18.48 0.464L19.248 0.832L19.88 1.736L19.792 2.32L19.416 4.76L18.68 8.584L18.2 11.144H18.48L18.8 10.824L20.096 9.104L22.272 6.384L23.232 5.304L24.352 4.112L25.072 3.544H26.432L27.432 5.032L26.984 6.568L25.584 8.344L24.424 9.848L22.76 12.088L21.72 13.88L21.816 14.024L22.064 14L25.824 13.2L27.856 12.832L30.28 12.416L31.376 12.928L31.496 13.448L31.064 14.512L28.472 15.152L25.432 15.76L20.904 16.832L20.848 16.872L20.912 16.952L22.952 17.144L23.824 17.192H25.96L29.936 17.488L30.976 18.176L31.6 19.016L31.496 19.656L29.896 20.472L27.736 19.96L22.696 18.76L20.968 18.328H20.728V18.472L22.168 19.88L24.808 22.264L28.112 25.336L28.28 26.096L27.856 26.696L27.408 26.632L24.504 24.448L23.384 23.464L20.848 21.328H20.68V21.552L21.264 22.408L24.352 27.048L24.512 28.472L24.288 28.936L23.488 29.216L22.608 29.056L20.8 26.52L18.936 23.664L17.432 21.104L17.248 21.208L16.36 30.768L15.944 31.256L14.984 31.624L14.184 31.016L13.76 30.032L14.184 28.088L14.696 25.552L15.112 23.536L15.488 21.032L15.712 20.2L15.696 20.144L15.512 20.168L13.624 22.76L10.752 26.64L8.48 29.072L7.936 29.288L6.992 28.8L7.08 27.928L7.608 27.152L10.752 23.152L12.648 20.672L13.872 19.24L13.864 19.032H13.792L5.44 24.456L3.952 24.648L3.312 24.048L3.392 23.064L3.696 22.744L6.208 21.016L6.2 21.024Z",fill:"#D97757"})),Xu=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 32 32",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M0 4C0 1.79086 1.79086 0 4 0H28C30.2091 0 32 1.79086 32 4V28C32 30.2091 30.2091 32 28 32H4C1.79086 32 0 30.2091 0 28V4Z",fill:"#F0F0F0"}),React.createElement("path",{d:"M14.5 8V12H17.5V8H19V12H20.5C20.7652 12 21.0196 12.1054 21.2071 12.2929C21.3946 12.4804 21.5 12.7348 21.5 13V17L18.5 21V23C18.5 23.2652 18.3946 23.5196 18.2071 23.7071C18.0196 23.8946 17.7652 24 17.5 24H14.5C14.2348 24 13.9804 23.8946 13.7929 23.7071C13.6054 23.5196 13.5 23.2652 13.5 23V21L10.5 17V13C10.5 12.7348 10.6054 12.4804 10.7929 12.2929C10.9804 12.1054 11.2348 12 11.5 12H13V8H14.5ZM15 20.5V22.5H17V20.5L20 16.5V13.5H12V16.5L15 20.5Z",fill:"#949494"})),Ku=()=>React.createElement("svg",{width:"40",height:"40",viewBox:"0 0 44 44",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("rect",{width:"44",height:"44",fill:"#357B49",rx:"6"}),React.createElement("path",{fill:"#fff",fillRule:"evenodd",d:"m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z",clipRule:"evenodd"})),qu=()=>React.createElement("svg",{width:"40",height:"40",style:{flex:"none",lineHeight:1},viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true"},React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"#3186FF"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-0)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-1)"}),React.createElement("path",{d:"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z",fill:"url(#lobe-icons-gemini-fill-2)"}),React.createElement("defs",null,React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-0",x1:"7",x2:"11",y1:"15.5",y2:"12"},React.createElement("stop",{stopColor:"#08B962"}),React.createElement("stop",{offset:"1",stopColor:"#08B962",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-1",x1:"8",x2:"11.5",y1:"5.5",y2:"11"},React.createElement("stop",{stopColor:"#F94543"}),React.createElement("stop",{offset:"1",stopColor:"#F94543",stopOpacity:"0"})),React.createElement("linearGradient",{gradientUnits:"userSpaceOnUse",id:"lobe-icons-gemini-fill-2",x1:"3.5",x2:"17.5",y1:"13.5",y2:"12"},React.createElement("stop",{stopColor:"#FABC12"}),React.createElement("stop",{offset:".46",stopColor:"#FABC12",stopOpacity:"0"}))));var{store:Qb}=No(Zb);function Ju(){try{return JSON.parse(document.getElementById("wp-script-module-data-options-connectors-wp-admin")?.textContent??"{}")}catch{return{}}}function Fs(){return Ju().connectors??{}}function $u(){return!!Ju().isFileModDisabled}var Jb={google:qu,openai:Uu,anthropic:Gu,akismet:Ku};function $b(e,t){if(t)return React.createElement("img",{src:t,alt:"",width:40,height:40});let o=Jb[e];return React.createElement(o||Xu,null)}var e0=()=>React.createElement("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:"var(--wpds-typography-font-weight-emphasis)",whiteSpace:"nowrap"}},(0,oo.__)("Connected")),t0=({slug:e})=>React.createElement(en,{href:(0,oo.sprintf)((0,oo.__)("https://wordpress.org/plugins/%s/"),e),openInNewTab:!0},(0,oo.__)("Learn more")),o0=()=>React.createElement(Ii,null,(0,oo.__)("Not available"));function ef({isConnected:e,showUnavailableBadge:t,pluginSlug:o,isExpanded:n,isBusy:r,pluginStatus:i,actionButtonRef:s,handleButtonClick:a,getButtonLabel:d}){return React.createElement(gr.__experimentalHStack,{spacing:3,expanded:!1},e&&React.createElement(e0,null),t&&(o?React.createElement(t0,{slug:o}):React.createElement(o0,null)),!t&&React.createElement(gr.Button,{ref:s,variant:n||e?"tertiary":"secondary",size:"compact",onClick:a,disabled:i==="checking"||r,isBusy:r,accessibleWhenDisabled:!0},d()))}function tf(e){let t=e?.replace(/\.php$/,"");return t?.includes("/")?t.split("/")[0]:t}function n0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="api_key"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentApiKey:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveApiKey:S,removeApiKey:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=c==="not-installed"&&l===!1||c==="inactive"&&f===!1,k=(0,js.useRef)(null);return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:T,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:k,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(Kb,{key:g?"connected":"setup",initialValue:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),k.current?.focus()},onSave:async C=>{await S(C),m(!1),k.current?.focus()}}))}function r0({name:e,description:t,logo:o,authentication:n,plugin:r}){let i=n?.method==="application_password"?n:void 0,s=i?.settingName??"",a=i?.credentialsUrl??void 0,d=tf(r?.file),{pluginStatus:c,canInstallPlugins:l,canActivatePlugins:f,isExpanded:p,setIsExpanded:m,isBusy:u,isConnected:g,currentUsername:v,hasResolvedSettings:_,keySource:w,handleButtonClick:y,getButtonLabel:b,saveCredentials:S,removeCredentials:x}=Ds({file:r?.file,settingName:s,connectorName:e,isInstalled:r?.isInstalled,isActivated:r?.isActivated,keySource:i?.keySource,initialIsConnected:i?.isConnected}),E=w==="env"||w==="constant",T=(0,js.useRef)(null),k=c==="not-installed"&&l===!1||c==="inactive"&&f===!1;return React.createElement(Zu,{className:d?`connector-item--${d}`:void 0,logo:o,name:e,description:t,actionArea:React.createElement(ef,{isConnected:g,showUnavailableBadge:k,pluginSlug:d,isExpanded:p,isBusy:u,pluginStatus:c,actionButtonRef:T,handleButtonClick:y,getButtonLabel:b})},p&&c==="active"&&_&&React.createElement(qb,{key:g?"connected":"setup",initialUsername:E?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":v,helpUrl:a,readOnly:g||E,keySource:w,onRemove:E?void 0:async()=>{await x(),T.current?.focus()},onSave:async C=>{await S(C),m(!1),T.current?.focus()}}))}function of(){let e=Fs(),t=o=>o.replace(/[^a-z0-9-_]/gi,"-");for(let[o,n]of Object.entries(e)){if(o==="akismet"&&!n.plugin?.isInstalled)continue;let{authentication:r}=n,i=t(o),s={name:n.name,description:n.description,type:n.type,logo:$b(o,n.logoUrl),authentication:r,plugin:n.plugin},a=No((0,Qu.select)(Qb)).getConnector(i);r.method==="api_key"&&!a?.render?s.render=n0:r.method==="application_password"&&!a?.render&&(s.render=r0),Xb(i,s)}}function nf(){return React.createElement("div",{className:"ai-plugin-callout__decoration","aria-hidden":"true"},React.createElement("svg",{viewBox:"0 0 248 248",xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",focusable:"false",style:{width:"100%",height:"100%"}},React.createElement("image",{href:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAAQAElEQVR4AezdC3ojWW5tYflOzPbIbI/M9sh8+WdrdZ+KpiiKL5FB5KedwN7AeSFIpHRYmfX/PubXVGAqMBV4kQpMw3qRBzXbnApMBT4+pmHNq2AqMBV4mQpMw3qZR3X9RmeGqcCrV2Aa1qs/wdn/VOCNKjAN640e9hx1KvDqFZiG9epPcPY/FThWgZ1q07B2+mDnWFOBPVZgGtYen+qcaSqw0wpMw9rpg51jTQX2WIFpWMee6mhTganAU1ZgGtZTPpbZ1FRgKnCsAtOwjlVltKnAVOApKzAN6ykfy2zqcRWYlV6pAtOwXulpzV6nAm9egWlYb/4CmONPBV6pAtOwXulpve9e//Nw9P/7xL8d7Hy9aQWubFhvWrU59qMr8D+HBcPBna93rcA0rHd98q91bs3q3w9bBv7Bna93rMA0rHd86nPmqcCLVmAa1os+uF/Y9m8u6Q7rvw8bgLnDOhTiXb+mYb3rk3+tc//rYbsaVTjQP18amct4+h9hftt3BaZh7fv57v107rNg7+ec831WYBrWZyHGPHUF/vewu//6xNqg+HMRfyjMrb+edb5pWM/6ZGZfawX86Bc0qTU2/htVYBrWGz3sOepU4NUrMA3r1Z/g7H8q8EYVmIZ1h4c9U04FpgL3qcA0rPvUdWadCkwF7lCBaVh3KOpMORWYCtynAtOw7lPXmfVdKjDnfGgFpmE9tNyz2FRgKnBNBaZhXVO9GTsVmAo8tALTsB5a7llsKjAVuKYCv9uwrtn5jJ0KTAXergLTsN7ukc+BpwKvW4FpWK/77GbnU4G3q8A0rLd75L914Fl3KnB9BaZhXV/DmWEqMBV4UAWmYT2o0LPMVGAqcH0FpmFdX8OZYSowFfhrBe7GpmHdrbQz8VRgKnDrCkzDunVFZ76pwFTgbhWYhnW30s7EU4GpwK0rMA3r1hW9fr6ZYSowFfiiAtOwvijMyFOBqcDzVWAa1vM9k9nRVGAq8EUFpmF9UZiRpwKPqMCs8bMKTMP6Wb0meyowFfjFCkzD+sXiz9JTganAzyowDetn9ZrsqcBU4Bcr8NIN6xfrNktPBaYCv1CBaVi/UPRZciowFbisAtOwLqvbjJoKTAV+oQLTsH6h6LPkBRWYIVOBQwWmYR2KMF9TganAa1RgGtZrPKfZ5VRgKnCowDSsQxHmayowFXimCny9l2lYX9dmIlOBqcCTVWAa1pM9kNnOVGAq8HUFpmF9XZuJTAWmAk9WgWlYT/ZArt/OzDAV2G8FpmHt99nOyaYCu6vANKzdPdK7HOjfDrP+9yf4B/fP138efoeDma+pwP0rMA3r/jXewwqaVFjP8x8HAmIHd74eXIG3W24a1ts98pse+H8Os8HBzNdU4P4VmIZ1/xrvYQU/9v3L4SCwNqh/P2iwagdpvqYC96nANKz71HVmnQpMBe5QgXduWHco526n9B3W9tJ91fi7Pfwc7HkqMA3reZ7FM+/kXw+bc7EeDvTPV1z8jzC/TQXuWYFpWPes7sw9FZgK3LQC07BuWs7dTva/h5P91ye6YGfT2EP4eb9mZ/uowDSsfTzHe5/CHVXQqKzHbjX6YCpwtwpMw7pbaX808f8dsoN7oQOdr6nAVGBbgWlY24o8hvvEzXcnp1YTl3cq51ExTdRe7GldE6ev2vhTgbtV4KyGdbfV33diDeC7T9bE5T1LlezFntb94PRVG38qcLcKTMO6W2lPTuy/GPdfiJfED+6G6Lg8/m/Dnuxne7lOe5Y9/naNZv0HVGAa1gOKfMYSGkI4I/1XUp59f79SlFn0sRWYhvXYereaex/3PyunQT9iiePlPMbOKlOBJ67ANKzfeTiakvufVsdDmjgtPnYq8PYVmIb1Oy8B9z7uf06tLi7vVM7EpgJvVYFpWM/xuF1mB3dFz7Gr2cXOK/B6x5uG9RzPzH1VeI4dzS6mAk9YgWlYT/hQZktTganA8QpMwzpel1GnAlOBJ6zANKyLH8oMnApMBR5dgWlYj674rDcVmApcXIFpWBeXbgZOBaYCj67ANKxHV3zWe8UKzJ6fpALTsJ7kQcw2pgJTge8rMA3r+xpNxlRgKvAkFZiG9SQPYrYxFZgKfF+BRzSs73cxGVOBqcBU4IwKTMM6o0iTMhWYCjxHBaZhPcdzmF1MBaYCZ1RgGtYZRXrSFP9Wln/gD/htEwd/mTqNT4Nyj2ny+7/3sDgYh4NxNBYHcdrH4Tc8HOifL/E044hsmjgN0tj2Ko6DcfLE8EADcfmAg1w64IMXrcA0rNd5cN6Y3njrjnE4pvkHANP58iCNxQO+B3x11v84HA6c9+DO1ytWYBrW6zw1/6Df/FtZlz8vtYPLZ5iRv16BaVi//gjO3sD2zYb7F0mB30Q4aHBpfBqU68cjHPjl4iHNPy5oDljH4yBeLh7SxNN+Mr7cY+PFmpNtLf52/2suv9yx11TgF8ZOw/qFor/gkt7kKzrCJZox9xzf3GN3WIFpWK/xUN27hHbsuyIXyyBGZ/FAgzgrh7aO59PE5AQaiKfJ2WriNCiPPZabxsqBa8ebw9rAD9agmX+r0QcvVoFpWK/xwLzxvOFcGq87pof0OHtKE5MDLqpxwAMO4mksDfggjgMecBBPY2lsEKdBGouDOB5oEGdx4Ac8pLFpY1+sAtOwfuuBXb+uN/H1s9x2hm0zWH/8u+1KM9tbVmAa1ms8dj/SuEh2odyOXUSHtTGksadyjZETjuWm/e/BKc+4A/1g09iPw69j2kH+OHe8XHMF89GOjaeXx+LAD41nt5rcwYtVYBrWiz2wZbvehBoZ8IVYPNAgzuKw5vK32prLD8dy08xRHosDP5TLbrVtLg7lscbRWDzQIM7isObyaYMXrMA0rBd8aN9s2Y9lodQ4m/ZK1r7Duu80Np0f0n7ZzvK3qsA0rFtV8vHzeFP6r9+Bbwesy/lAgzgrh8YaC+t3I3SclRdoEGflbDU6TYwfaBBn5Ww1fN2nPB820ECcZiweaLDmyqGBcSunDV6sAtOwXuyBPWC73tTe9NulaNuLfhqsuY1nV13eOeONMRb4t4Q93HK+mevBFZiG9eCC33i5ay+S3efAui3fibjc3+o06625OH3VjKOZZ9V9aCB/1XC5q2YczTyrfq7vgt5YWMc076qN/2IVeIGG9WIVfdx2vSG9CaFVaRpDSI+zcuisxgDrHMXYFfLhO018m0eDrY6D2AoapNmjvUN7FcdDueLyQU762B1UYBrWDh7iHGEq8C4VmIa1vyfdJbTvNDqdi/Ww3g3JgfKe1TpT+2+/zpHGtne6HEgbu5MKTMPayYNcjuENC9sL7iXl765LaPi78MvOLZZ3dmc65/y3WG/meGAFpmE9sNhPuJQ7HhffT7i1i7fkTODy/eJJZuBzVmAa1nM+l2t21SW0S+fm4Qdv5lV/hR+dNNXt/p0jje1M6a9wrvY89swKTMM6s1AvnuZNHF7xKO092xnibNrYF6nAJduchnVJ1Z57jO8sQjt1aR3c8aS/inWe7f6dI43tLPSQNnYnFZiGtZMHuRzDhTOsl869gdkl9cOna7Bqz+g7i72H9hhnV00DU4O0sTupwDSsnTzIOcZU4B0qMA3rRZ/yiW27gAYX1aXxw3rf0wV9ec9qfeJ3bP9pbHv346NzqUHa2J1UYBrWTh7kcgwNKSR7E4e0V7LtnXU2e2fxQBvsvALTsHb+gD+P544nfEovZdo7u24cD6s+/k4rMA1rfw/WJTq4eO50/OANvtXjz2pdoLd/31HZp3OksTQQx1n89TEn+HsFpmH9vRRv6XjTw94O70w+Wdzbud7+PNOw9vcScLcDLqq/O52Lafgu75Xizu5M60X8K+1/9nqiAtOwThTnRUPerLD+SORTs+AN3dH4EH9W6zztv3PZdxrb3ukhbexOKrD/hrWTBzXHmApMBT4+pmHt71Xgwhn6TsQJXcIH9zs04AP/meE87b9z2Xca2/7pciBt7E4qMA1rJw9yOYY3LJxz6awRwDL85V1n96niOed/+cO+2wGmYb3bE//reXd21/PncJ3pnA8d/gyY316nAtOwXudZnbvTfzkkgovqg/vnix+8of+Ih9/SDu5Tf/nEr722fzaN7QDp8yNhFdmRnYa1o4d54ijexOFE2tOG2nu2jcbZtLE7rsA0rP09XN9ZhE7nniq440l/Fes82/07Rxr7KmeZfV5RgaVhXTHLDH2mCrhwhvXS2Zs7rHutEazaM/rO0v7Z9sgPac7kU8NpYlVkR3Ya1o4e5hzlLxWYS/e/lGMfZBrWPp7jeoouqNl0fljve3w3sl5Yl/9sVvM5tv80tj07kw8d2LSxO6nANKydPMjlGN6omhIk0wLt1dDe2c7F4uHVzjT7vaAC07AuKNoLDumeh33B7X/Yd/hYfqWxizzuXiswDWt/T9aFM6yXzvywvrnTnr0KPkRor76jsl/nSGNpII6z+GBHFZiGtaOHecFRvOnhgqFPPcSZfLL41Jt81Ob2tM40rD09zb+dxd0OuKj+m/L17y7c4euM14v4zsqZ1ov41zvF7PhoBaZhHS3LS4verOCN20F8ahY0s3Q+xJ/VOk/771z2ncauexeDVRt/BxWYhrWDhzhHmAq8SwWmYX3zpF8w7MIZ+k7EEVzCB/c7NOAD/5nhPO2/c9l3Gtv+6XIgbexOKjANaycPcjmGNyycc+msEcAy/OVdZ/ep4jnnf/nDvtsBpmG92xP/63nd88Bf1ddmzgPnfOjw2id9w91Pw9rfQ3cBDS6qOx0/eDNv9fizWp/4bffvHGlse0//+Y+EzTD2aSswDetpH81NN+ZNHG468YMma+/Zlo2zaWN3XIFpWPt7uL6zCJ0uzrrjSX+Utaa1gW9dFg+0r7Dm8strLJs2dscVmIa1v4frwhnWS2c8rCf26Rqs2j381mZrOCwO617pPghg24uckM6mseVqXs5kjrSxO6nA7RrWTgoyx3iaCmhIT7OZ2chzVGAa1nM8h1vuwgV1aN44u973uJyH8u5lfWJnbWh9Fg+tTXeJ7jultGPjxRrL4mCcM5kDH+yoAtOwdvQwP4/iDQve+J/SBx4+fuFXa7Pti8WBf2pbckK5bBp7avzEdlKBaVg7eZDfHMOPV+Gb1HPCP85pbbbB/BXpx+xXeV/px+YYbQcVmIa1g4e4OYILZ1gvnfnBm7wh8mDVil1jfcezzulSvPXFzC2eJk4DcTqLgzgN0tfxdHkgjrP4YEcVmIa1o4f5w6N4w/9wyFnp5tVgzkr+Iskc6yeHX6R9KV87/suJJ/C7FZiG9bv1v8fq7nbARfWp+eW4rIZTeT+NmdeFN/vTsfKNsyfAf4prx/90vb3nP9X5pmE91eO4yWY0C1h/JPKpWfCGbiE5sGrFrrHb+eyn9a1nbjZNnAbGirE4iJcrRhNPY2lAl8Pigx1VYBrWjh7mHGUqsPcKTMPa3xN24Qy+y+h0LtaD+x06Kwfwe8Ia9gTWtRaLgzjtK8hpoPtu7gAAEABJREFU/+Uan8Y2li4H0sbupALTsO77IH9jdm9YOOfS2uU4yL/3Xq0B6zo4nLPXddwp33zOdMs5T603sQdWYBrWA4s9Sz2kAu6u4LsPHR6ymVnkthWYhnXbej7DbC6gwUV1++EHb2Y6Kw/4tHvBj2fWgdZi29N3nwiKl2ucfbJpLA3SrYkPdlSBaVg7epgnjuJNHE6kPTzUnthTi4uvKPeYVuzhdha8fwWmYd2/xo9ewR0OrN9h8INYe+JD/FmtPR7bfxr7rHuffd2wAtOwbljMJ5nKJ2qwXjq7hA5tUxOQB/z0Z7TtnW2vLB7at+blU0PnShu7kwpMw9rJg5xjTAXeoQJP07DeodgPOqML6tCSx+56aC6rgV/uM1qf+NkjrPvDQ7rvsJxJDdLG7qQC07B28iCXY3jDgjdysjdwWHU+lPes1nnaP98+7TuNpQUxiI/dSQWmYe3kQX5zDPc94ZvUpwy3d3bdIB5WffydVmAa1v4erAtnWC+d+cEb3KlZecCnPQY/X8XFevvvOyx7TmObVRxn08bupALTsHbyIOcYf6mAZrZ+SvqX4JDXrcA0rNd9dl/t3N0NuKj+Kocux8U04HvBXs+1l+dz1TmmYV1Vvqcc7AIa1h+J/JWY4A3dxuXAqhV7Jus87d9+7c2e01ga0OWw+OBXK3Dbxadh3baeM9tUYCpwxwpMw7pjcR8wtbsaaCm+S/SQHmflpL+K9R2Ti3Ro/6zzhM4iJ8hJH7uDCkzDeu2H2Bvz0lNoBHDp+EeO03zCqXXLYU/lTewFKzAN66kf2rebc08D3yZ+kaBZXTP+i2lHngrcpwLTsO5T10fN6jIaWk/zwUN6nJWTzm457dmgsdo7tF8WD+05zspJH7uDCkzD2sFD3BzBmzQUirNpr2btPbT3OHtKKzb2xSswDet1H6A7Gt95AL+T4CFNPKTtwXZOtvPwgzNv9VUr9gx29nBGBaZhnVGkJ03xxvNXVoBvmyweaEC/9oLePM8EZ+qc63/Vnsa2Xw0MB+PSx75YBaZhvdgDm+1OBd65AtOwXvfpu7cJ6ynS2HTfYbiEhrQ9WH+tKHQe515Bx8vj0wYvWIG9NKwXLP3VW/bG04BAQzLhqtFpQQzir26dxbmB33mcO6SJywN++tgXq8A0rBd7YMt23cWERf5IYz/u+Mv8K1rqmFbsEfbY+se0R+xl1rhxBaZh3bigd5rOhfn2r6B4E9LBdw6WXjU67RZo3tYxZ5p1XGbTAA84GNf+jaMBH/iXwFhzs41vbTadxUF+uXScTRv7xBWYhvXED+fJtuZNvX4ad4vtmVMT+dFcS7Lxa7NcQme5144/a5FJul0FpmHdrpb3nMm/beXuBe65zqm5u7Q+lXMqZu+w5vjuZv2nYdbYOb753Fex5+Rvc6x/zfjtfMPvXIFpWHcu8I2m743lzdWUNG924NO9cfFAuwXMaw22+fDWWfeVxq65cmCdo/il1lywjrduKLbulb/ml7Nq4z9pBaZhPemDmW1NBaYCHx8fmyJMw9oU5AmoexXfBbBtpwtrNl2O+x9IK/8aay5Y57CGtcG6YiwO4jTAAw7iacbRrEGHNPq1MJc5oblop9Zfcxsz9gkrMA3r+R6KS2Twhv5ud3JCubg3aPyn1nhvYPanY3+abw245WW+ucwJ3+1HTvgud+JPUIFpWE/wEDZb6IJ9I59N3cnA2QM2ica6YGc3oaFTgd+twDSs363/sdV9d+Rymi2OhxqJ+FYr/285sZ9ZY829jtLAtmvJSRMvP41NE8fB/HQWB3HaLWAuc0LzrXu1Lp2VE2iDJ6/ANKwnf0Cf2/PmCp/SH5PG/hHu9Jv5V7TMJZoxp8YXu9SaP6xzpLHp/JA29okrMA3r+R6O7wZCu4uz7lzS+RC/hbVGaD5rpPHpbBpLA37AYc3lbzX5tHvBmtYAfuvgIU08jZ8+9gkqMA3rdx+CNwS0C74L95AeZ9PkuhwHfvq11hqhueJsa7E4uOg+lntMM47OGgvrePqtz2RO6wB/uz6dBvyAvwjeY5vTsH73OXvzwLFd+FHlmH5P7au93HPNY3PbBxyLjfbGFZiG9bsPvx891l1oVODTwnQ8rFoXxmLp11qX1qG57MUakMYP4sf0NPFy0+Ks+Kr7L9XVJu1aa43OxG8+fkizl2O5xcf+YgWmYf1i8Y8s7c1TE1rfsGmsnIbyIX6tNZd1Q/Ph1gY+XS4OaXQ84CCexqd9NV7s1rCWdYFvfrY9sTSQE/DBE1VgGtafh/FUv/lRKLSxOJt2L2uN0Bpx9pj2lX4q15gV5T7SHlv/mPbIPc1aJyowDetEce4c8qf4uX9dxCV08IZqa3yIX2vN1Tps87mExsG+6WuuOA3kBBzE04yjsWniNDA/ncVvAWtVa745WesEGsRZOTTg33JP5hz8sALTsH5YsCdL9yaCJ9vWVdvxiaEzsVdNdOPBmirceNqZ7icVmIb1k2rdNtcdSrh0Zn/iw6Xjn3GcS291YW+5P3PCpXO6iF/vui6dZ8ZdUYFpWFcU78qh3jzeAFDTofmELLREnJWTfmtrbmuE5rfHtPbKpomXm8amieNgHJ3FQZwGdJzFbwHnMifwzclaO9AgzsqhAR/4g1+qwDSsXyr8LDsVmAr8vALTsH5es0tGuJPxHQM0nu9iF8TpLB5oEGfl0G453lzmNLc1Ag3E0+RsNXEalMfiII7DT8Yfy01jzResA3FWDu3Y+mJygjyIs3Jo63g+bfALFfhpw/qFLe5iSZe1sF4k870hQgeNs6c0MTkBhziLAz/gIc1etppYmjge0uPiW00sTRwP6XHxrSaWJo6H9Dh7ShOTE3CIszjwAx7S7CVt7IMrMA3rwQWf5aYCU4HLKzAN6/La/WSky16XuGzj+tSJ1mUui4dy46wcuh9NcEhj8SAP4qwc2jreXmhicgINxNPk0I6Np5fH4nDp+NY6Nl7MGsE6EGfl0Na9prFygjyIs3Jo63h7oQ1+oQLTsH6h6J9LejOs+JQ/ztU+Dr/OzT2Wdxj+cUz/u/bxj1+rxi/CD+dq8s/NPZZ3yXjzGBdwiLM48AMe0ti0sQ+uwDSsxxTcn9DBXYhV2a1GT2Nx4AfjaOxWo6exOPCDcTQ2jaUBP+Cw5vK3mnwa8AMOxqTxaZDG4sAP5bJbbZuLQ3mscTQWBz4N8IBDnC2XxYM8iLP44M4VmIZ15wJ/Tu/CPXxKH3HWG4LO4oEGcRaHNZe/1dZcfpAHxqR1kbxqYvKAH+TQ2LTG09NYOV9p3uRy4KvxxoKc0JxsGisP+AGHNZe/1eTTgB9wMCbtu73KH9ypAtOw7lTYN53WG/tNj/7ix36R7U/DesyDcu+xwqr+6kkaHtLYn2ryjQs4xFkc+MFeaJDG4iCOAw78IL7VitHFV06Ls+I0wAMO4luNnsbiwA84xFk84OFczV7KdQEf0sbesQLTsO5Y3GVqnziFZD8SpfHp3jxpLA34QQ6NTbv3ePOfWkvcnqA8FgdxHPi0Y/unywlyaMak8WliaSwN+EEOjU271XjzmivggztXYBrWnQv8Ob0flcKn9BHPfnz+irOf0gc/fCy/0thkfjiliZXH4sAPOMSzNIizOPADDvEsDeIsDvyAQzxLgziLAz/gIY39qSbfuIBDnMUHd67ANKxbFPgfc3jh9u8u+ZO3iL/mEeTQXeJuNbE0Vh7wgxya+beaWBorD/hBDm0dby80sfJYGojjIId2bDxdTsDh0vGtdWy8WOuw1gF+kENb95rGlsfKA36QQ1vH2wtNrDyWFsTyx96wAtOwbljMmWoq8FkBDUyT+6RjblWBaVi3quQ/5nFfEv6hjvdOFfC3GrwG3unMDznrNKzbltmLtMtdfrN7AYd0eWn9aSyWxp4ab4wc4Mu9dLy9nBovbh1oLRYHceMBDziIpxlHY9PEaZDGOg9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4P/y8fFhLzSxNJa2Qnzl49+gAtOwblDEmWIqMBV4TAWmYV1XZ5er0Cz+JHZ/Afx0PJQvvtXE0thHjbcXa321vrj9gBy5q8angZyAg3jaT8Yfy01jm5O1DvCDHNqx9cXKY+UBP8ihreP5NLHyWBqI4yCHNrhRBaZhXV5IL0Yvyj41aiY6rH+FAw/liaex6fxwShMrj8WBH3CIs3jAwV62Gj1NHA/pcfGtJpYmjof0uPhWE0sTx0N6nD2lickJOMRZHPgBD2n2stXE0sRxSBt7owo8uGHdaNfPM417ivW/fH6enc1OpgI7rMA0rMsfqmblAtaPAM1C669qsOn8IIeu0W01epq5cUhjG8/iwJcHOPxkvHxjgR9wOLZXa5bHygN+wOHS8db4ajy9dVgc+KHx7Fbb5uJQHmscjcUDDeIsDny1B+NogxtVYBrWjQr5OY0XqAYG/E/5Aw8fn7/ibLksDvzP1A88fHz+Ek/jk9mtRk9jcTiWu2prLj/I+Wo8vTwWB364x3hzWwf4AQdrpvG3mhgN+AEHY9L4W02MBuIBH9ywAtOwLi+mOwovVOCbicUDDeKsHBqLA58GeMAhzpbL4sCXB3jAIc6Wy+JBHsRZHNZc/lZbc/kA8sAYHPhbjU4DfjiWm7bNxaGxbLksDnx5gAcc4my5LB7kQZzFYc3lb7U1V2zwgwpMw/pBsTapLtvD+sJMc/nakDT2mHZs/Fe5jTdGDvDpLB5oEGdxWHPb66qtufwg56vx9PJYHPjhHuPNbR3gBxysmcbfamI04AccjEm7Za3MPfhBBaZh/aBYkzoVmAr8bgWmYV1efxfJ27uKOCve7HhIE99qYmksDvyAQ5zFAx7O1ezlVK74qTnFHz3eeu2JxYEfcIizeMDDudotz9qaL28fdYBpWJdX2l2ET4KAbyYvfhzS6HiQQxNP49PE0lga8IMcGpt27/HmP7WWuD1BeSwO4jjwacf2T5cT5NCMSePTxNJYGvCDHBqbdu/x5j+1lrg9DS6owDSsC4r2OcS9xopP+eMSzZiPz1/88Cl9xNmP5RcekuPsKU1MTsAhzuLAX0GDSzRjjAV+wCHO4sBfQYNztZ/kHpvz0vHmMhb4AR9cUIFpWOcVzZ+K/qt2thEuYWnghUgXx0GcBngoV3yriaWxxgI/yKGta6Wx5bHygB/k0Nbx9kITK4+lgTgOcmjHxtPlBBwuHd9ax8aLtQ5rHeAHObR1r2lseaw84Ac5tHW8vdDEymNpII6DHNqx8fTBDyswDeu8gvlkyIuPPW/ED7Im9a0r4B98fOsC/OTw07DOq5aLVvcg7HkjJmsqcF4Fjv3TNOeNfMOsaVjnPXTf0rtIZRuBe7FBOouDeLl40Pjo4mnG0cTSWBrwgxyaMWl8mlgaSwN+kEMzJs1eaGJpLA3EcTCOxuIgTgM84CCeZhyNTROnQRprPzRxHIyjieGBBnFWDs0YHPg0MTzQIM7KoRmDg73QxPBAA/E042hsmjhtcEEFpmFdUFjYef8AAA5ZSURBVLQZMhW4uAIz8KoKTMP65/K5q4I14gI1FPOn5ilNvDnKY0+NF5MTHjW+vX61vnh7kmNfq8anQXksDuI4/GT8sdw01nzBOhBn5dCOrS8mJ8iDOCuHto7n08TkBBqIp8nZauI0KI/FB99UYBrWPxfIiwd6scngBxxcwKexNOCDOA54wEE8jaUBP+AQZ/GAh59q8htrLziksTiI44EGcXEc0lgcxPFAg7g4DmksDuJ4oEGcxYEf8JDG/lSTbxzYCw54wEE8jaUBH8RxwAM++KYC07C+KdCEpwJTgeepwGs1rMfUrctRl6qt6N84Cuk+MdxqYmnsT8fLNw7Wy1k8WEMeu9XoaSwO/GAcjU1jacAPOFx6VmPh0vH2+NV4evtkceCD+jWepQFfHuABhzhbLosHeRBncfjJWe0xGDv4pgLTsL4p0GfYvUP4lD7irBf0x+EXiwP/IP35wsMf4fBbnC2XxYF/SPvzhYc/wuE38TT+Qfpg09iPz1/88Cl9rLn8j8Mvtjz2IP354gc5RHar0dNYHPjBOBq71ehpLA78YByNTWNpwAdxHPg04G81Og34AQdj0vhbTYwG/HAsN00uP+CDbyowDeuvBXKf0IuNLcoPcujsVqOnsTjwg3E0dqvR01gc+ME4GrvV6GksDvxgHI1NY2nADzisufytJp8G/ICDMWl8GqSxOPBDuexW2+biUB5rHI3FgU8DPOAgnsbfamI04AccjEnjbzUxGvADDsYEfLBUYBrWUoyD64Xir1bAejmKh0Pan684axzRiw+HS8abx9hgToizOKy5/K225vKDPDAmrb2umpg84Ac5NDat8fQ0Fgd+MI72Ta0+5MsDfmg8m8bKA37AYc3lbzX5NOAHHIxJ66yrJiYP+EEOjU1rPD2NxUGuD30AHywVmIa1FONM17fwZ6ZO2gtWQMN4wW2/x5anYf31OWtGweVp0TQ2TRyHNH4QP6aniZebxqaxOPADDnEWD3g4V7OXU7nip+YUf/R467UnFgd+wCHO4gEP52r3Pqv9uMSH9jT2swLTsD4L8Wm8WPrUxo8sn/Kf/z15epp4Gp9+zng5co05NV5MHvBD49k0c8lbNTEa8IMcGpv23XjxU7ni5oTyWBzEceDTjq1PlxPk0IxJ49PE0lga8IMcGpt27/HmP7WWuD1BeSwO9ioH8HfA2WechvXPpfIjQSgaZ49pX+nHco9pl4w3j3EBhziLAz/gIY09V/tJ7rE5f2O8fVg34BBnceAHPKSx52o/yT025zq++Fvbd29Y/hSDXgR8l52wXoTioVzxtF5Y54wv99h4seZkW4sf5NDXtdLY8lh5wA9yaOt4e6GJlcfSQBwHObRj4+lyAg6Xjm+tY+PFWoe1DvCDHNq61zS2PFYeHFtrHS8u79rx5rBuwMH8adagDT4r8M4Ny4vBi2P91OazLGOmAlOBZ6zAOzcsz8PFJvBfFbPv21bAH2S3nXFmu1kF3rlhdbnJVlDf+vdXc9aL0DS2XHEcjKOzOIjTAA+tJ55mnDyxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaZDG2g9NHAfjaGJ4oEGclUMzBgc+TQwPNIizcmjG4GAvNDE80EA8zTgamyZOgzQWB3EcjKMNPivwzg3rswRjpgJTgVepwDs3LH96bS83V43fcyyPTRPHoR8jVo1frpxwLDeNLY991Pj2+tX64vYDcuxr1fg0kBNwEE/7yfhjuWlsc7LWAX6QQzu2vlh5rDw4lrtqfHnXjjeHdQMO5k+zBg3o/3lwVu1A3+vrnRuWy3YPP/Tk4+JbTSxNHA/pcfGtJpYmjof0OHtKE5MTcIizOPADHtLsZauJpYnjIT0uvtXE0sTxkB4X32piaeJ4SI+zpzQxOQGHOIvDuhYe5ID4VqOnieMhPS6+1cTSxPGQ7gMioKe9nX3nhvV2D3sOfFYF3rohnFWhX0x654blr1j4hBBcoHoMLB5oEGdxuHS8Nb4aT7dGwCHONp7FgS8P8IBDnC2XxYM8iLM4XHpWY+HS8fb41Xi6PQYc4i6vG8+m8+VBGosDP5TLprHygB9wuPSsxsKx8XSX8OBHQ/wt8VYNa/OEPfjgBSnMbjV6GosDPxhHY7caPY3FgR+Mo7FpLA34AYc1l7/V5NOAH3AwJo2/1cRowA/HctO2uTg0li2XxYEvD/CAQ5wtl8WDPIiL48Df6qsmJg/4AYc1l7/V5NOAH47lpm1zcWgsu+aKvT3epWH5Nj/00HEvCuDTWTzQIM7KobE48LcanQb8cCw3bZuLQ2PZclkc+PIADzjE2XJZPMiDOIvDmsvfamsuP8gDY9L4W02MBvxwLDdtm4tDY9lyWRz48gAPOIin8beaGA34AQdj0vhbTYwG/ICDMWn8rSZGe0u8S8PqUxe2B+0CM6wvjDSXn8dyj2n3GG8fx9ZKs6Yc4NNZPNAgzuKw5nbWVVtz+UHOV+Pp5bE48MM9xpvbOsAPOFgzjb/VxGjgNYIDDsbg8Ey1sre3wrs0rLd6qHPYj4+PKcIuK/AuDauLUbYH6XLTHQGk8YP4MT1NvNy0OCt+TE8TlwdpLB5wiLM48AMe0thzNXs5lStuPjiWJ35MTxM3FtL4QfyYniZebhqbxuLADzjEWTzg4VzNXk7lip+aU/xW45vnbey7NCw/94ceLu6TJODTvdBwSKPjQQ5NPI1PE9tq9DRWDs0YHPg0MTzQIM7KobE43Hu8+a0D/K/Wp8sJOBiTxqcd2z+9PFYOzRgc+DQxPNAgzsqhsTjce7z5rQP8r9anywk4GJPGpx3bP/3t8C4N6+0e7Bx4KrDHChxvWPs7qYtU6E8sJ8T/7+BAOouD+CH85wsPLmCJ4mnG0dg0cRqksafGi8kJxkKclUNb1+LTxOQEGsRZOTRjcGivYniQB+JpxtHYNHEapLE4iONgHI3FQZwGeLAfmniacTSxNJYG/CCHZkwanyaWxtKAH+TQjEmzF5pYGksDcRyMo7E4iNMADziIpxlHY9PEaW+Jd2lYXlzQJzxv+bDn0FOBV6/AuzQsdwCwXni++rOb/U8F3q4C79KwtpeYHjTNX3UA33LTWBzEaYAHjY8mnmYcjU0Tp0Eae2q8mJxgLMRZObR1LT5NTE6gQZyVQzPmXz4+PmjtVQwP8kA8zTgamyZOgzQWB3EcjKOxOIjTAA/2QxNPM44mlsbSgB/k0IxJ49PE0lga8IMcmjFp9kITS2NpII6DcTQWB3Ea4AEH8TTjaGyaOO0t8S4N6y0f7hx6KrC3Crxzw/KnlgtMcL/l2a4anwZywrHcNLa8a8ebx9rAD9agmX+riaWx8oAf5NDW8XyaWHksDcRxkLPVxGkgJ+AgnvaT8cdy09jmZK0D/CCHdmx9sfJYecAPcmjreD5NrDyWBuI4yNlq4jSQE3AQT2s8nQ/8t8U7NywX8F4AsL4AcBBPx0OaeBpLZ4M4DdJYHMTxQIM4iwM/4CGN/akm3ziwFxzwgIN4GksDPojjgAccxNNYGvBBHAc84CCextKAH3CIs3jAw081+Y21FxzSWBzE8UCDuDgOaSwO4niggb8WpJHR8Uvw8mPeuWG9/MObA7xVBXxg5N7srQ69Pew7Nyx/TccFJvRCYOmhesVZOXQvIBzSWDzIgzgrh3ZsPF1OwCHONp7FgS8PcHAuHPBQLpvGygN+wOHYXh8x3hpfrU9vnywO/NB4dqttc3EojzWOxuKBBnEWh3vUyrx+VPRM7QV/S7xzw/LgQw8f98IAfjoejmnlsuXxj+Ue08plG88ey01bc/l01jjg0wAPOIin8beaGA344Vhu2jYXh8ay5bI48OUBHnCIs+WyeJAHcRaHNZe/1dZcfpAHxqTxt5oYDfjhWG7aNheHxrJrrtjb450b1rGH735gRTlePEGczp7SxOQBPxhHY7caPY3FgR+Mo7FbjZ7G4sAPxtHYNJYG/IDDmsvfavJpwA84GJPG32piNOCHY7lp21wcGsuWy+LAlwd4wCHOlsviQR7EWRzWXP5WW3P5QR4YE/DBUoFpWEsxDq4XiotNcMl5kP588cMf4fBbnDXuIH2wOLg8/fj8hYdP6SPOGkdn8UCDOIvDmsvfamsuP8gDY9La66qJyQN+kENj0xpPT2Nx4AfjaGzatePNY07gBxzWtfhbTT4N+AEHY9La66qJyQN+kENj0xpPT2NxkOv1B3za4LMC07A+CzFmKjAVeP4KTMP66zNyZxBcnhZNY9PEcUjjB/Fjepp4uWlsGosDP+AQZ/GAh3M1ezmVK35qTvFHj7dee2Jx4Acc4iwe8PAX7UDoB/Pnix/ufVbruMQH/p8NzG9/q8A0rL/Vod+9QHwSA+4W0vEghy6exqeJbTV6GiuHZgwOfJoYHmgQZ+XQWBzuPd781gH+V+vT5QQcjEnj047tn14eK4dmDA58mhgeaBBn5dBYHO493vzWAf5X69PlBBzs1TjAB0sFpmEtxRh3KjAVeO4KTMN67ufzTLv7yQXwT3Kf6YyzlyevwF0a1pOfebZ3ugKajX8sbpvlkyyxVceP/ejiE641b/ypwE0qMA3rJmXc1STuUPxTJttDuWsRW3X8WMM6Nn4dN/5U4KIKTMO6qGwzaCowFfiNCkzD+o2q72nNOctU4IEVmIb1wGLPUlOBqcB1FZiGdV39ZvRUYCrwwApMw3pgsWepqcBrV+D3dz8N6/efwexgKjAVOLMC07DOLNSkTQWmAr9fgWlYv/8MZgdTganAmRWYhnVmoa5PmxmmAlOBayswDevaCs74qcBU4GEVmIb1sFLPQlOBqcC1FZiGdW0FZ/xU4J8rMMqdKjAN606FnWmnAlOB21dgGtbtazozTgWmAneqwDSsOxV2pp0KTAVuX4H/DwAA//9sB2hHAAAABklEQVQDAB9QlitZA9bLAAAAAElFTkSuQmCC",width:"248",height:"248",style:{mixBlendMode:"multiply"}})))}var i0="ai",s0="ai-wp-admin",Vs="ai/ai",a0="https://wordpress.org/plugins/ai/",Ys=Object.values(Fs()),c0=Ys.some(e=>e.type==="ai_provider"),af=[];for(let e of Ys)e.type==="ai_provider"&&e.authentication.method==="api_key"&&af.push(e.authentication.settingName);function cf(){let[e,t]=(0,wt.useState)(!1),[o,n]=(0,wt.useState)(!1),r=(0,wt.useRef)(null);(0,wt.useEffect)(()=>{o&&r.current?.focus()},[o]);let i=(0,wt.useRef)(Ys.some(S=>S.type==="ai_provider"&&S.authentication.method==="api_key"&&S.authentication.isConnected)).current,{pluginStatus:s,canInstallPlugins:a,canManagePlugins:d,hasConnectedProvider:c}=(0,ln.useSelect)(S=>{let x=S(Ws.store),E=!!x.canUser("create",{kind:"root",name:"plugin"}),T=x.getEntityRecord("root","site"),k=i||af.some(A=>!!T?.[A]),C=x.getEntityRecord("root","plugin",Vs);return x.hasFinishedResolution("getEntityRecord",["root","plugin",Vs])?C?{pluginStatus:C.status==="active"?"active":"inactive",canInstallPlugins:E,canManagePlugins:!0,hasConnectedProvider:k}:{pluginStatus:"not-installed",canInstallPlugins:E,canManagePlugins:E,hasConnectedProvider:k}:{pluginStatus:"checking",canInstallPlugins:E,canManagePlugins:void 0,hasConnectedProvider:k}},[]),{saveEntityRecord:l}=(0,ln.useDispatch)(Ws.store),{createSuccessNotice:f,createErrorNotice:p}=(0,ln.useDispatch)(rf.store),m=async()=>{t(!0);try{await l("root","plugin",{slug:i0,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin installed and activated successfully."),{id:"ai-plugin-install-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to install the AI plugin."),{id:"ai-plugin-install-error",type:"snackbar"})}finally{t(!1)}},u=async()=>{t(!0);try{await l("root","plugin",{plugin:Vs,status:"active"},{throwOnError:!0}),n(!0),f((0,Xe.__)("AI plugin activated successfully."),{id:"ai-plugin-activate-success",type:"snackbar"})}catch{p((0,Xe.__)("Failed to activate the AI plugin."),{id:"ai-plugin-activate-error",type:"snackbar"})}finally{t(!1)}};if(!c0||s==="checking"||s==="active"&&i&&!o||s==="inactive"&&d===!1)return null;let g=s==="active"&&!c,v=s==="active"&&c&&(!i||o),_=s==="not-installed"||s==="inactive",w=s==="not-installed"&&a===!1,y=()=>v?(0,Xe.__)("The AI plugin is ready to use. You can use it to generate featured images, alt text, titles, excerpts and more. Learn more"):g?(0,Xe.__)("The AI plugin is installed. Connect an AI provider below to generate featured images, alt text, titles, excerpts, and more. Learn more"):(0,Xe.__)("The AI plugin can use your AI connectors to generate featured images, alt text, titles, excerpts and more. Learn more"),b=()=>s==="not-installed"?{label:e?(0,Xe.__)("Installing\u2026"):(0,Xe.__)("Install the AI plugin"),disabled:e,onClick:e?void 0:m}:{label:e?(0,Xe.__)("Activating\u2026"):(0,Xe.__)("Activate the AI plugin"),disabled:e,onClick:e?void 0:u};return React.createElement("div",{className:"ai-plugin-callout"},React.createElement("div",{className:"ai-plugin-callout__content"},React.createElement("p",null,(0,wt.createInterpolateElement)(y(),{strong:React.createElement("strong",null),a:React.createElement(cn.ExternalLink,{href:a0})})),!w&&(_?React.createElement(cn.Button,{variant:"primary",size:"compact",isBusy:e,disabled:b().disabled,accessibleWhenDisabled:!0,onClick:b().onClick},b().label):React.createElement(cn.Button,{ref:r,variant:"secondary",size:"compact",href:(0,sf.addQueryArgs)("options-general.php",{page:s0})},(0,Xe.__)("Control features in the AI plugin")))),React.createElement(nf,null))}var{store:d0}=No(l0);of();function u0(){let e=$u(),{connectors:t,canInstallPlugins:o,isAiPluginInstalled:n}=(0,lf.useSelect)(c=>{let l=c(uf.store),f=l.getEntityRecord("root","plugin","ai/ai");return{connectors:No(c(d0)).getConnectors(),canInstallPlugins:l.canUser("create",{kind:"root",name:"plugin"}),isAiPluginInstalled:!!f}},[]),r=t.filter(c=>c.render),i=Array.from(new Set(t.filter(c=>c.type==="ai_provider").map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c))).sort(),s=new Set(t.filter(c=>c.plugin?.isInstalled).map(c=>c.plugin?.file?.split("/")[0]).filter(c=>!!c));n&&s.add("ai");let a=["ai",...i].filter(c=>!s.has(c)),d=r.length===0;return React.createElement(Bs,{title:(0,Tt.__)("Connectors"),subTitle:(0,Tt.__)("All of your API keys and credentials are stored here and shared across plugins. Configure once and use everywhere.")},React.createElement("div",{className:`connectors-page${d?" connectors-page--empty":""}`},a.length>0&&(e||!o)&&React.createElement(tn.Root,{intent:"info",className:"connectors-page__file-mods-notice"},React.createElement(tn.Description,null,e?(0,Tt.__)("Plugins cannot be installed here due to your site configuration. Install them manually using your normal deployment workflow."):(0,Tt.__)("You do not have permission to install plugins. Please ask a site administrator to install them for you."))),d?React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:3,style:{maxWidth:480}},React.createElement(dt.__experimentalVStack,{alignment:"center",spacing:2},React.createElement(dt.__experimentalHeading,{level:2,size:15},(0,Tt.__)("No connectors yet")),React.createElement(dt.__experimentalText,{size:12},(0,Tt.__)("Connectors appear here when you install plugins that use external services. Each plugin registers the API keys it needs, and you manage them all in one place."))),React.createElement(dt.Button,{variant:"secondary",href:"plugin-install.php",__next40pxDefaultSize:!0},(0,Tt.__)("Learn more"))):React.createElement(dt.__experimentalVStack,{spacing:3},React.createElement(cf,null),React.createElement(dt.__experimentalVStack,{spacing:3,role:"list"},t.map(c=>c.render?React.createElement(c.render,{key:c.slug,slug:c.slug,name:c.name,description:c.description,type:c.type,logo:c.logo,authentication:c.authentication,plugin:c.plugin}):null))),o&&!e&&React.createElement("p",null,(0,df.createInterpolateElement)((0,Tt.__)("If the connector you need is not listed, search the plugin directory to see if a connector is available."),{a:React.createElement("a",{href:"plugin-install.php?s=connector&tab=search&type=tag"})}))))}function f0(){return React.createElement(u0,null)}var p0=f0;export{p0 as stage}; /*! Bundled license information: use-sync-external-store/cjs/use-sync-external-store-shim.production.js: diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index df48a061af01e..1cd9dfa120e89 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -319,6 +319,7 @@ "radius": true }, "dimensions": { + "width": true, "dimensionSizes": [ { "name": "25%", From 312b034308cc2b8d222534a6f7d74a71294d6fff Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 5 Aug 2026 12:59:21 +0000 Subject: [PATCH 329/336] Coding Standards: Correct alignment of assignment operators. This resolves a WPCS warning: {{{ Equals sign not aligned with surrounding assignments }}} Follow-up to [62590], [62838]. Props Soean. See #64897. git-svn-id: https://develop.svn.wordpress.org/trunk@63027 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-users-list-table.php | 2 +- src/wp-includes/pluggable.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-users-list-table.php b/src/wp-admin/includes/class-wp-users-list-table.php index dd54b200bafaf..1212c2db531e5 100644 --- a/src/wp-admin/includes/class-wp-users-list-table.php +++ b/src/wp-admin/includes/class-wp-users-list-table.php @@ -635,7 +635,7 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0 if ( $primary === $column_name ) { $row .= $this->row_actions( $actions ); } - $tag = ( $primary === $column_name ) ? 'th' : 'td'; + $tag = ( $primary === $column_name ) ? 'th' : 'td'; $row .= ""; } } diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index e1c43540c8cb8..c7694e4cf8d11 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2376,7 +2376,7 @@ function wp_new_user_notification( $user_id, $deprecated = null, $notify = '' ) $switched_locale = switch_to_user_locale( $user_id ); - $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; + $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; /* * Since some user login names end in a period, this could produce ambiguous URLs that From 2768106d9c32a529a368ea801e34982ff0252753 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 5 Aug 2026 13:03:06 +0000 Subject: [PATCH 330/336] Tests: Add unit tests for `wp_privacy_exports_url()`. This adds coverage for the personal data exports directory URL, verifying both the default location under the uploads directory and that the filter of the same name can override it. Developed in: https://github.com/WordPress/wordpress-develop/pull/5551 Follow-up to [63025]. Props desrosj, masteradhoc, mindctrl, pbearne, wildworks. Fixes #59709. git-svn-id: https://develop.svn.wordpress.org/trunk@63028 602fd350-edb4-49c9-b593-d223f7449a82 --- .../tests/functions/wpPrivacyExportsUrl.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/phpunit/tests/functions/wpPrivacyExportsUrl.php diff --git a/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php b/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php new file mode 100644 index 0000000000000..6891640d172b0 --- /dev/null +++ b/tests/phpunit/tests/functions/wpPrivacyExportsUrl.php @@ -0,0 +1,40 @@ +assertSame( trailingslashit( $upload_dir['baseurl'] ) . 'wp-personal-data-exports/', wp_privacy_exports_url() ); + } + + /** + * @ticket 59709 + */ + public function test_wp_privacy_exports_url_filtered() { + add_filter( 'wp_privacy_exports_url', array( $this, 'filter_wp_privacy_exports_url' ) ); + + $upload_dir = wp_upload_dir(); + $expected_url = trailingslashit( $upload_dir['baseurl'] ) . 'filtered-exports/'; + $actual_url = wp_privacy_exports_url(); + $this->assertSame( $expected_url, $actual_url ); + } + + /** + * Filters the personal data exports directory URL for tests. + * + * @param string $exports_url Default exports directory URL. + * @return string Filtered exports directory URL. + */ + public function filter_wp_privacy_exports_url( $exports_url ) { + return str_replace( 'wp-personal-data-exports/', 'filtered-exports/', $exports_url ); + } +} From 462a0507dd2d1350339a67ca6cca0bbd1860458a Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Wed, 5 Aug 2026 13:19:30 +0000 Subject: [PATCH 331/336] Media: Fix positioning of active spinner. Two positioning issues: on desktop, the active spinner appeared off screen, generating a scrollbar in the media toolbar. In the attachment details modal, the spinner overlapped with the `Saved` confirmation. On desktop, limit some positioning assignments to only apply with the media modal. In the attachment details, apply `display: flex` to prevent overlapping. Developed in https://github.com/WordPress/wordpress-develop/pull/12797 Props afercia, rcorrales, joedolson. Fixes #65778. git-svn-id: https://develop.svn.wordpress.org/trunk@63029 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/css/media-views.css | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 227be604f7852..6748a50f00c57 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -364,9 +364,9 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { grid-area: 2 / 2 / 3 / 3; } -.media-toolbar-secondary > .spinner { +.media-modal .media-toolbar-secondary > .spinner { position: absolute; - left: calc( 100% + 2px ); + right: -30px; top: 50%; margin: 0; } @@ -1855,6 +1855,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { text-align: right; text-transform: none; font-weight: 400; + display: flex; } .attachment-details .settings-save-status .spinner { @@ -2842,7 +2843,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { float: right; } - .media-frame .media-toolbar-secondary .spinner { + .media-modal .media-frame .media-toolbar-secondary .spinner { top: calc( 50% - 8px ); } @@ -2877,7 +2878,7 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { bottom: -60px; } - .media-frame .media-toolbar-secondary .spinner { + .media-modal .media-frame .media-toolbar-secondary .spinner { top: 0; } @@ -2900,13 +2901,9 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { position: unset; } - .media-frame .media-toolbar-secondary .spinner { - position: absolute; - top: 0; + .media-modal .media-frame .media-toolbar-secondary .spinner { bottom: 0; margin: auto; - left: calc( 100% + 2px ); - right: 0; z-index: 9; } From b9de76505ec2139156ee6d8aa269eb8868d0f65e Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 5 Aug 2026 13:52:42 +0000 Subject: [PATCH 332/336] I18N: Move trailing spaces out of translatable strings. Follow-up to [6873], [10888], [31059]. Props khokansardar, jorbin, audrasjb, rcorrales, SergeyBiryukov. See #64899. git-svn-id: https://develop.svn.wordpress.org/trunk@63030 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/category-template.php | 2 +- src/wp-includes/pluggable.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index cd8304f24fdc0..76409d0832f2e 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -1230,7 +1230,7 @@ function get_the_tag_list( $before = '', $sep = '', $after = '', $post_id = 0 ) */ function the_tags( $before = null, $sep = ', ', $after = '' ) { if ( null === $before ) { - $before = __( 'Tags: ' ); + $before = __( 'Tags:' ) . ' '; } $the_tags = get_the_tag_list( $before, $sep, $after ); diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index c7694e4cf8d11..b283844836b83 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2090,7 +2090,7 @@ function wp_notify_moderator( $comment_id ) { $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; /* translators: %s: Trackback/pingback/comment author URL. */ $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; - $notify_message .= __( 'Trackback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + $notify_message .= sprintf( __( 'Trackback excerpt: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; break; case 'pingback': @@ -2101,7 +2101,7 @@ function wp_notify_moderator( $comment_id ) { $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; /* translators: %s: Trackback/pingback/comment author URL. */ $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; - $notify_message .= __( 'Pingback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + $notify_message .= sprintf( __( 'Pingback excerpt: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; break; default: // Comments. From 884f5156ddd9e820bec0b8f6550cfb7f8aa53f99 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 5 Aug 2026 14:02:21 +0000 Subject: [PATCH 333/336] Upgrade/Install: Add removed icon files to `$_old_files`. This adds the icon files removed during the 7.1 release to the `$_old_files` list. Follow up to [62738], [62739]. Props courane01, wiildworks. Fixes #65489. See #65813. git-svn-id: https://develop.svn.wordpress.org/trunk@63031 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/update-core.php | 244 ++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/src/wp-admin/includes/update-core.php b/src/wp-admin/includes/update-core.php index 89589c2ed384e..ce3eb82bc9e71 100644 --- a/src/wp-admin/includes/update-core.php +++ b/src/wp-admin/includes/update-core.php @@ -900,6 +900,250 @@ // 7.0.2 'wp-includes/collaboration', 'wp-includes/collaboration.php', + // 7.1 + 'wp-includes/images/icon-library/accordion-heading.svg', + 'wp-includes/images/icon-library/accordion-item.svg', + 'wp-includes/images/icon-library/accordion.svg', + 'wp-includes/images/icon-library/add-card.svg', + 'wp-includes/images/icon-library/add-submenu.svg', + 'wp-includes/images/icon-library/add-template.svg', + 'wp-includes/images/icon-library/align-center.svg', + 'wp-includes/images/icon-library/align-justify.svg', + 'wp-includes/images/icon-library/align-left.svg', + 'wp-includes/images/icon-library/align-none.svg', + 'wp-includes/images/icon-library/align-right.svg', + 'wp-includes/images/icon-library/archive.svg', + 'wp-includes/images/icon-library/aspect-ratio.svg', + 'wp-includes/images/icon-library/background.svg', + 'wp-includes/images/icon-library/backup.svg', + 'wp-includes/images/icon-library/bell-unread.svg', + 'wp-includes/images/icon-library/border.svg', + 'wp-includes/images/icon-library/box.svg', + 'wp-includes/images/icon-library/breadcrumbs.svg', + 'wp-includes/images/icon-library/brush.svg', + 'wp-includes/images/icon-library/bug.svg', + 'wp-includes/images/icon-library/button.svg', + 'wp-includes/images/icon-library/buttons.svg', + 'wp-includes/images/icon-library/cancel-circle-filled.svg', + 'wp-includes/images/icon-library/caption.svg', + 'wp-includes/images/icon-library/caution-filled.svg', + 'wp-includes/images/icon-library/classic.svg', + 'wp-includes/images/icon-library/close-small.svg', + 'wp-includes/images/icon-library/close.svg', + 'wp-includes/images/icon-library/cloud-download.svg', + 'wp-includes/images/icon-library/cloud-upload.svg', + 'wp-includes/images/icon-library/cloud.svg', + 'wp-includes/images/icon-library/code.svg', + 'wp-includes/images/icon-library/cog.svg', + 'wp-includes/images/icon-library/color.svg', + 'wp-includes/images/icon-library/column.svg', + 'wp-includes/images/icon-library/columns.svg', + 'wp-includes/images/icon-library/comment-author-avatar.svg', + 'wp-includes/images/icon-library/comment-author-name.svg', + 'wp-includes/images/icon-library/comment-content.svg', + 'wp-includes/images/icon-library/comment-edit-link.svg', + 'wp-includes/images/icon-library/comment-reply-link.svg', + 'wp-includes/images/icon-library/connection.svg', + 'wp-includes/images/icon-library/contents.svg', + 'wp-includes/images/icon-library/copy-small.svg', + 'wp-includes/images/icon-library/copy.svg', + 'wp-includes/images/icon-library/corner-all.svg', + 'wp-includes/images/icon-library/corner-bottom-left.svg', + 'wp-includes/images/icon-library/corner-bottom-right.svg', + 'wp-includes/images/icon-library/corner-top-left.svg', + 'wp-includes/images/icon-library/corner-top-right.svg', + 'wp-includes/images/icon-library/crop.svg', + 'wp-includes/images/icon-library/currency-dollar.svg', + 'wp-includes/images/icon-library/currency-euro.svg', + 'wp-includes/images/icon-library/currency-pound.svg', + 'wp-includes/images/icon-library/custom-link.svg', + 'wp-includes/images/icon-library/custom-post-type.svg', + 'wp-includes/images/icon-library/dashboard.svg', + 'wp-includes/images/icon-library/details.svg', + 'wp-includes/images/icon-library/drafts.svg', + 'wp-includes/images/icon-library/drag-handle.svg', + 'wp-includes/images/icon-library/filter.svg', + 'wp-includes/images/icon-library/flip-horizontal.svg', + 'wp-includes/images/icon-library/flip-vertical.svg', + 'wp-includes/images/icon-library/footer.svg', + 'wp-includes/images/icon-library/format-bold.svg', + 'wp-includes/images/icon-library/format-capitalize.svg', + 'wp-includes/images/icon-library/format-indent-rtl.svg', + 'wp-includes/images/icon-library/format-indent.svg', + 'wp-includes/images/icon-library/format-italic.svg', + 'wp-includes/images/icon-library/format-list-bullets-rtl.svg', + 'wp-includes/images/icon-library/format-list-bullets.svg', + 'wp-includes/images/icon-library/format-list-numbered-rtl.svg', + 'wp-includes/images/icon-library/format-list-numbered.svg', + 'wp-includes/images/icon-library/format-lowercase.svg', + 'wp-includes/images/icon-library/format-ltr.svg', + 'wp-includes/images/icon-library/format-outdent-rtl.svg', + 'wp-includes/images/icon-library/format-outdent.svg', + 'wp-includes/images/icon-library/format-rtl.svg', + 'wp-includes/images/icon-library/format-strikethrough.svg', + 'wp-includes/images/icon-library/format-underline.svg', + 'wp-includes/images/icon-library/format-uppercase.svg', + 'wp-includes/images/icon-library/full-height.svg', + 'wp-includes/images/icon-library/fullscreen.svg', + 'wp-includes/images/icon-library/funnel.svg', + 'wp-includes/images/icon-library/gift.svg', + 'wp-includes/images/icon-library/globe.svg', + 'wp-includes/images/icon-library/grid.svg', + 'wp-includes/images/icon-library/handle.svg', + 'wp-includes/images/icon-library/header.svg', + 'wp-includes/images/icon-library/heading-level-1.svg', + 'wp-includes/images/icon-library/heading-level-2.svg', + 'wp-includes/images/icon-library/heading-level-3.svg', + 'wp-includes/images/icon-library/heading-level-4.svg', + 'wp-includes/images/icon-library/heading-level-5.svg', + 'wp-includes/images/icon-library/heading-level-6.svg', + 'wp-includes/images/icon-library/help-filled.svg', + 'wp-includes/images/icon-library/home-button.svg', + 'wp-includes/images/icon-library/html.svg', + 'wp-includes/images/icon-library/inbox.svg', + 'wp-includes/images/icon-library/insert-after.svg', + 'wp-includes/images/icon-library/insert-before.svg', + 'wp-includes/images/icon-library/institution.svg', + 'wp-includes/images/icon-library/justify-bottom.svg', + 'wp-includes/images/icon-library/justify-center-vertical.svg', + 'wp-includes/images/icon-library/justify-center.svg', + 'wp-includes/images/icon-library/justify-left.svg', + 'wp-includes/images/icon-library/justify-right.svg', + 'wp-includes/images/icon-library/justify-space-between-vertical.svg', + 'wp-includes/images/icon-library/justify-space-between.svg', + 'wp-includes/images/icon-library/justify-stretch-vertical.svg', + 'wp-includes/images/icon-library/justify-stretch.svg', + 'wp-includes/images/icon-library/justify-top.svg', + 'wp-includes/images/icon-library/keyboard-close.svg', + 'wp-includes/images/icon-library/keyboard-return.svg', + 'wp-includes/images/icon-library/keyboard.svg', + 'wp-includes/images/icon-library/layout.svg', + 'wp-includes/images/icon-library/level-up.svg', + 'wp-includes/images/icon-library/lifesaver.svg', + 'wp-includes/images/icon-library/line-dashed.svg', + 'wp-includes/images/icon-library/line-dotted.svg', + 'wp-includes/images/icon-library/line-solid.svg', + 'wp-includes/images/icon-library/link-off.svg', + 'wp-includes/images/icon-library/link.svg', + 'wp-includes/images/icon-library/list-item.svg', + 'wp-includes/images/icon-library/list-view.svg', + 'wp-includes/images/icon-library/list.svg', + 'wp-includes/images/icon-library/lock-outline.svg', + 'wp-includes/images/icon-library/lock-small.svg', + 'wp-includes/images/icon-library/lock.svg', + 'wp-includes/images/icon-library/login.svg', + 'wp-includes/images/icon-library/loop.svg', + 'wp-includes/images/icon-library/math.svg', + 'wp-includes/images/icon-library/media-and-text.svg', + 'wp-includes/images/icon-library/media.svg', + 'wp-includes/images/icon-library/megaphone.svg', + 'wp-includes/images/icon-library/more.svg', + 'wp-includes/images/icon-library/move-to.svg', + 'wp-includes/images/icon-library/navigation-overlay.svg', + 'wp-includes/images/icon-library/navigation.svg', + 'wp-includes/images/icon-library/not-allowed.svg', + 'wp-includes/images/icon-library/not-found.svg', + 'wp-includes/images/icon-library/offline.svg', + 'wp-includes/images/icon-library/overlay-text.svg', + 'wp-includes/images/icon-library/page-break.svg', + 'wp-includes/images/icon-library/page.svg', + 'wp-includes/images/icon-library/pages.svg', + 'wp-includes/images/icon-library/pending.svg', + 'wp-includes/images/icon-library/percent.svg', + 'wp-includes/images/icon-library/pin-small.svg', + 'wp-includes/images/icon-library/pin.svg', + 'wp-includes/images/icon-library/plugins.svg', + 'wp-includes/images/icon-library/plus-circle-filled.svg', + 'wp-includes/images/icon-library/position-center.svg', + 'wp-includes/images/icon-library/position-left.svg', + 'wp-includes/images/icon-library/position-right.svg', + 'wp-includes/images/icon-library/post-author.svg', + 'wp-includes/images/icon-library/post-categories.svg', + 'wp-includes/images/icon-library/post-comments-count.svg', + 'wp-includes/images/icon-library/post-comments-form.svg', + 'wp-includes/images/icon-library/post-comments.svg', + 'wp-includes/images/icon-library/post-content.svg', + 'wp-includes/images/icon-library/post-date.svg', + 'wp-includes/images/icon-library/post-excerpt.svg', + 'wp-includes/images/icon-library/post-featured-image.svg', + 'wp-includes/images/icon-library/post-list.svg', + 'wp-includes/images/icon-library/post-terms.svg', + 'wp-includes/images/icon-library/post.svg', + 'wp-includes/images/icon-library/preformatted.svg', + 'wp-includes/images/icon-library/pull-left.svg', + 'wp-includes/images/icon-library/pull-right.svg', + 'wp-includes/images/icon-library/pullquote.svg', + 'wp-includes/images/icon-library/query-pagination-next.svg', + 'wp-includes/images/icon-library/query-pagination-numbers.svg', + 'wp-includes/images/icon-library/query-pagination-previous.svg', + 'wp-includes/images/icon-library/query-pagination.svg', + 'wp-includes/images/icon-library/redo.svg', + 'wp-includes/images/icon-library/remove-bug.svg', + 'wp-includes/images/icon-library/remove-submenu.svg', + 'wp-includes/images/icon-library/replace.svg', + 'wp-includes/images/icon-library/reset.svg', + 'wp-includes/images/icon-library/resize-corner-ne.svg', + 'wp-includes/images/icon-library/reusable-block.svg', + 'wp-includes/images/icon-library/rotate-left.svg', + 'wp-includes/images/icon-library/rotate-right.svg', + 'wp-includes/images/icon-library/row.svg', + 'wp-includes/images/icon-library/seen.svg', + 'wp-includes/images/icon-library/send.svg', + 'wp-includes/images/icon-library/separator.svg', + 'wp-includes/images/icon-library/shipping.svg', + 'wp-includes/images/icon-library/shortcode.svg', + 'wp-includes/images/icon-library/sidebar.svg', + 'wp-includes/images/icon-library/sides-all.svg', + 'wp-includes/images/icon-library/sides-axial.svg', + 'wp-includes/images/icon-library/sides-bottom.svg', + 'wp-includes/images/icon-library/sides-horizontal.svg', + 'wp-includes/images/icon-library/sides-left.svg', + 'wp-includes/images/icon-library/sides-right.svg', + 'wp-includes/images/icon-library/sides-top.svg', + 'wp-includes/images/icon-library/sides-vertical.svg', + 'wp-includes/images/icon-library/site-logo.svg', + 'wp-includes/images/icon-library/square.svg', + 'wp-includes/images/icon-library/stack.svg', + 'wp-includes/images/icon-library/stretch-full-width.svg', + 'wp-includes/images/icon-library/stretch-wide.svg', + 'wp-includes/images/icon-library/subscript.svg', + 'wp-includes/images/icon-library/superscript.svg', + 'wp-includes/images/icon-library/swatch.svg', + 'wp-includes/images/icon-library/tab.svg', + 'wp-includes/images/icon-library/table-column-after.svg', + 'wp-includes/images/icon-library/table-column-before.svg', + 'wp-includes/images/icon-library/table-column-delete.svg', + 'wp-includes/images/icon-library/table-of-contents.svg', + 'wp-includes/images/icon-library/table-row-after.svg', + 'wp-includes/images/icon-library/table-row-before.svg', + 'wp-includes/images/icon-library/table-row-delete.svg', + 'wp-includes/images/icon-library/tabs-menu-item.svg', + 'wp-includes/images/icon-library/tabs-menu.svg', + 'wp-includes/images/icon-library/tabs.svg', + 'wp-includes/images/icon-library/term-count.svg', + 'wp-includes/images/icon-library/term-description.svg', + 'wp-includes/images/icon-library/term-name.svg', + 'wp-includes/images/icon-library/text-color.svg', + 'wp-includes/images/icon-library/text-horizontal.svg', + 'wp-includes/images/icon-library/text-vertical.svg', + 'wp-includes/images/icon-library/thumbs-down.svg', + 'wp-includes/images/icon-library/thumbs-up.svg', + 'wp-includes/images/icon-library/time-to-read.svg', + 'wp-includes/images/icon-library/title.svg', + 'wp-includes/images/icon-library/tool.svg', + 'wp-includes/images/icon-library/trash.svg', + 'wp-includes/images/icon-library/trending-down.svg', + 'wp-includes/images/icon-library/trending-up.svg', + 'wp-includes/images/icon-library/typography.svg', + 'wp-includes/images/icon-library/undo.svg', + 'wp-includes/images/icon-library/ungroup.svg', + 'wp-includes/images/icon-library/unlock.svg', + 'wp-includes/images/icon-library/unseen.svg', + 'wp-includes/images/icon-library/update.svg', + 'wp-includes/images/icon-library/video.svg', + 'wp-includes/images/icon-library/widget.svg', + 'wp-includes/images/icon-library/word-count.svg', + 'wp-includes/images/icon-library/wordpress.svg', /* * Added back in 7.1. * From 2d97c2ee6986c3c6e36d540f5b39fef276c51990 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 5 Aug 2026 14:27:59 +0000 Subject: [PATCH 334/336] Upgrade/Install: Correct `$_old_files` ordering for accuracy. The `wp-includes/js/dist/sync.js` and `wp-includes/js/dist/sync.min.js` files were removed in 7.0.2 and added to the `$_old_files` list (see [62778]), but they are present again in `trunk`. [62783] marked these files as reintroduced, but this comment should be moved above the file list added for 7.1. Follow up to [62777], [62783], [63031]. See #65813, #65325. git-svn-id: https://develop.svn.wordpress.org/trunk@63032 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/update-core.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/update-core.php b/src/wp-admin/includes/update-core.php index ce3eb82bc9e71..664fec95298c6 100644 --- a/src/wp-admin/includes/update-core.php +++ b/src/wp-admin/includes/update-core.php @@ -900,6 +900,12 @@ // 7.0.2 'wp-includes/collaboration', 'wp-includes/collaboration.php', + /* + * Restored in WordPress 7.1. + * + * 'wp-includes/js/dist/sync.js', + * 'wp-includes/js/dist/sync.min.js', + */ // 7.1 'wp-includes/images/icon-library/accordion-heading.svg', 'wp-includes/images/icon-library/accordion-item.svg', @@ -1144,12 +1150,6 @@ 'wp-includes/images/icon-library/widget.svg', 'wp-includes/images/icon-library/word-count.svg', 'wp-includes/images/icon-library/wordpress.svg', - /* - * Added back in 7.1. - * - * 'wp-includes/js/dist/sync.js', - * 'wp-includes/js/dist/sync.min.js', - */ ); /** From d5b458991216f79f45a78f075badee6f6aaf7443 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 5 Aug 2026 15:10:21 +0000 Subject: [PATCH 335/336] WordPress 7.1 RC 1. git-svn-id: https://develop.svn.wordpress.org/trunk@63033 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 121a44ba90167..c4720b37f947c 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.1-beta4-62899-src'; +$wp_version = '7.1-RC1-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. From 7b887ba4820e0ee87bbf3f14a0e8385b33f1a6fd Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 5 Aug 2026 15:32:55 +0000 Subject: [PATCH 336/336] Post WordPress 7.1 RC 1 version bump. git-svn-id: https://develop.svn.wordpress.org/trunk@63034 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index c4720b37f947c..985bfaf0bf868 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.1-RC1-src'; +$wp_version = '7.1-RC1-63034-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.