From d0afb031e8bb880732bc356289455089a1a16b3a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:19:10 +0200 Subject: [PATCH 01/10] Add HTML style attribute processor --- ...lass-wp-html-style-attribute-processor.php | 938 ++++++++++++++++++ src/wp-settings.php | 1 + .../wpHtmlStyleAttributeProcessor.php | 289 ++++++ 3 files changed, 1228 insertions(+) create mode 100644 src/wp-includes/html-api/class-wp-html-style-attribute-processor.php create mode 100644 tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php new file mode 100644 index 0000000000000..8c76fca09c7dc --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -0,0 +1,938 @@ + + */ + private $tokens = array(); + + /** + * Parsed declarations. + * + * @var array + */ + private $declarations = array(); + + /** + * Index of the current declaration, or -1 before the first declaration. + * + * @var int + */ + private $current_declaration = -1; + + /** + * Lexical updates to apply to the original style attribute value. + * + * @var array + */ + private $lexical_updates = array(); + + /** + * Sequence counter used to keep lexical updates in queue order. + * + * @var int + */ + private $lexical_update_order = 0; + + /** + * Constructor. + * + * @param string $style Decoded style attribute value. + */ + public function __construct( string $style ) { + $this->style = $style; + $this->parse(); + } + + /** + * Moves to the next declaration in the style attribute value. + * + * When a property name is provided, normal CSS properties are matched + * ASCII-case-insensitively while custom properties are matched exactly. + * + * @param string|null $property_name Optional property name to match. + * @return bool Whether a matching declaration was found. + */ + public function next_declaration( ?string $property_name = null ): bool { + for ( $i = $this->current_declaration + 1; $i < count( $this->declarations ); $i++ ) { + if ( + null === $property_name || + $this->matches_property_name( $this->declarations[ $i ]['name'], $property_name ) + ) { + $this->current_declaration = $i; + return true; + } + } + + $this->current_declaration = count( $this->declarations ); + return false; + } + + /** + * Gets the current declaration's property name. + * + * @return string|null Property name, or null when not on a declaration. + */ + public function get_property_name(): ?string { + $declaration = $this->get_current_declaration(); + return null === $declaration ? null : $declaration['name']; + } + + /** + * Gets the current declaration's CSS value. + * + * The returned value does not include a trailing !important priority. + * + * @return string|null CSS value, or null when not on a declaration. + */ + public function get_value(): ?string { + $declaration = $this->get_current_declaration(); + if ( null === $declaration ) { + return null; + } + + return trim( + substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + } + + /** + * Indicates whether the current declaration has an !important priority. + * + * @return bool Whether the current declaration has an !important priority. + */ + public function is_important(): bool { + $declaration = $this->get_current_declaration(); + return null !== $declaration && $declaration['important']; + } + + /** + * Sets the value of the current declaration. + * + * @param string $value CSS declaration value, without the property name. + * @param bool|null $important Optional. Whether the declaration should be important. + * Defaults to preserving the current priority. + * @return bool Whether the current declaration was updated. + */ + public function set_value( string $value, ?bool $important = null ): bool { + $declaration = $this->get_current_declaration(); + if ( null === $declaration || ! $this->is_valid_declaration_value( $value ) ) { + return false; + } + + $is_important = null === $important ? $declaration['important'] : $important; + $text = $this->serialize_declaration( $declaration['raw_name'], $value, $is_important ); + + $this->queue_lexical_update( + $declaration['start'], + $declaration['after'] - $declaration['start'], + $text, + $this->current_declaration + ); + + return true; + } + + /** + * Removes the current declaration from the style attribute value. + * + * Only the current declaration is removed. Other declarations with the same + * property name remain in place. + * + * @return bool Whether the current declaration was removed. + */ + public function remove_declaration(): bool { + $declaration = $this->get_current_declaration(); + if ( null === $declaration ) { + return false; + } + + if ( '' === trim( substr( $this->style, 0, $declaration['leading_start'] ), self::WHITESPACE ) ) { + $remove_start = $declaration['leading_start']; + $remove_end = $declaration['trailing_end']; + } else { + $remove_start = $declaration['leading_start']; + $remove_end = $declaration['after']; + } + + $this->queue_lexical_update( + $remove_start, + $remove_end - $remove_start, + '', + $this->current_declaration + ); + + return true; + } + + /** + * Appends a declaration to the style attribute value. + * + * Appending does not remove or replace existing declarations with the same + * property name. + * + * @param string $property_name CSS property name. + * @param string $value CSS declaration value, without the property name. + * @param bool $important Optional. Whether the declaration should be important. Default false. + * @return bool Whether the declaration was appended. + */ + public function append_declaration( string $property_name, string $value, bool $important = false ): bool { + if ( ! $this->is_valid_property_name( $property_name ) || ! $this->is_valid_declaration_value( $value ) ) { + return false; + } + + $trimmed_style = rtrim( $this->style, self::WHITESPACE ); + $insert_at = strlen( $trimmed_style ); + $separator = $this->has_queued_append_at( $insert_at ) ? ' ' : ''; + + if ( '' === $separator && '' !== trim( $trimmed_style, self::WHITESPACE ) ) { + $separator = ( ';' === substr( $trimmed_style, -1 ) ) ? ' ' : '; '; + } + + $this->queue_lexical_update( + $insert_at, + 0, + $separator . $this->serialize_declaration( $property_name, $value, $important ), + null + ); + + return true; + } + + /** + * Returns the updated style attribute value. + * + * @return string Updated style attribute value. + */ + public function get_updated_style(): string { + if ( empty( $this->lexical_updates ) ) { + return $this->style; + } + + usort( + $this->lexical_updates, + static function ( $a, $b ) { + if ( $a['start'] !== $b['start'] ) { + return $a['start'] - $b['start']; + } + + return $a['order'] - $b['order']; + } + ); + + $updates = $this->merge_overlapping_lexical_updates( $this->lexical_updates ); + + $bytes_already_copied = 0; + $output = ''; + + foreach ( $updates as $update ) { + $output .= substr( $this->style, $bytes_already_copied, $update['start'] - $bytes_already_copied ); + $output .= $update['text']; + $bytes_already_copied = $update['start'] + $update['length']; + } + + $output .= substr( $this->style, $bytes_already_copied ); + + return $output; + } + + /** + * Parses CSS tokens and declarations from the style attribute value. + */ + private function parse(): void { + $processor = WP_CSS_Token_Processor::create( $this->style ); + if ( null === $processor ) { + return; + } + + while ( $processor->next_token() ) { + $start = $processor->get_token_start(); + $length = $processor->get_token_length(); + $this->tokens[] = array( + 'type' => $processor->get_token_type(), + 'value' => $processor->get_token_value(), + 'start' => $start, + 'length' => $length, + 'end' => $start + $length, + ); + } + + $this->parse_declaration_list(); + } + + /** + * Parses the token list according to the CSS declaration-list shape. + */ + private function parse_declaration_list(): void { + $index = 0; + $count = count( $this->tokens ); + $previous_item_ends_at = 0; + + while ( $index < $count ) { + $leading_start = $previous_item_ends_at; + $index = $this->skip_ignored_tokens( $index ); + + while ( $index < $count && WP_CSS_Token_Processor::TOKEN_SEMICOLON === $this->tokens[ $index ]['type'] ) { + $previous_item_ends_at = $this->tokens[ $index ]['end']; + $leading_start = $previous_item_ends_at; + ++$index; + $index = $this->skip_ignored_tokens( $index ); + } + + if ( $index >= $count ) { + break; + } + + if ( WP_CSS_Token_Processor::TOKEN_AT_KEYWORD === $this->tokens[ $index ]['type'] ) { + list( $index, $previous_item_ends_at ) = $this->consume_at_rule( $index ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE === $this->tokens[ $index ]['type'] ) { + $previous_item_ends_at = $this->tokens[ $index ]['end']; + ++$index; + continue; + } + + list( $declaration_end, $after_declaration, $has_semicolon ) = $this->consume_declaration_segment( $index ); + $declaration = $this->parse_declaration_segment( $leading_start, $index, $declaration_end, $after_declaration ); + + if ( null !== $declaration ) { + $declaration['trailing_end'] = $this->get_trailing_ignored_end( $declaration_end + ( $has_semicolon ? 1 : 0 ) ); + $this->declarations[] = $declaration; + } + + $index = $declaration_end + ( $has_semicolon ? 1 : 0 ); + $previous_item_ends_at = $after_declaration; + } + } + + /** + * Consumes an at-rule and returns the token index and byte offset after it. + * + * @param int $index At-keyword token index. + * @return array{int,int} Token index and byte offset after the at-rule. + */ + private function consume_at_rule( int $index ): array { + $count = count( $this->tokens ); + ++$index; + + while ( $index < $count ) { + $token = $this->tokens[ $index ]; + + if ( WP_CSS_Token_Processor::TOKEN_SEMICOLON === $token['type'] ) { + return array( $index + 1, $token['end'] ); + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + return $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); + } + + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); + continue; + } + + ++$index; + } + + $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + return array( $count, $after ); + } + + /** + * Consumes a simple block. + * + * @param int $index Token index for the opening token. + * @param string $closing_type Expected closing token type. + * @return array{int,int} Token index and byte offset after the block. + */ + private function consume_simple_block( int $index, string $closing_type ): array { + $count = count( $this->tokens ); + ++$index; + + while ( $index < $count ) { + $token = $this->tokens[ $index ]; + + if ( $closing_type === $token['type'] ) { + return array( $index + 1, $token['end'] ); + } + + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); + continue; + } + + ++$index; + } + + $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + return array( $count, $after ); + } + + /** + * Consumes a declaration candidate up to the next top-level semicolon or EOF. + * + * @param int $index Starting token index. + * @return array{int,int,bool} End token index, byte offset after candidate, and whether it ended in a semicolon. + */ + private function consume_declaration_segment( int $index ): array { + $count = count( $this->tokens ); + + while ( $index < $count ) { + $token = $this->tokens[ $index ]; + + if ( WP_CSS_Token_Processor::TOKEN_SEMICOLON === $token['type'] ) { + return array( $index, $token['end'], true ); + } + + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); + continue; + } + + ++$index; + } + + $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + return array( $count, $after, false ); + } + + /** + * Parses a declaration from a token segment. + * + * @param int $leading_start Byte offset before leading trivia. + * @param int $start_index First token index in the segment. + * @param int $end_index Token index after the segment. + * @param int $after Byte offset after the declaration. + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool}|null + */ + private function parse_declaration_segment( int $leading_start, int $start_index, int $end_index, int $after ): ?array { + $index = $this->skip_ignored_tokens( $start_index, $end_index ); + if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_IDENT !== $this->tokens[ $index ]['type'] ) { + return null; + } + + $name_token = $this->tokens[ $index ]; + $name = $name_token['value']; + if ( null === $name ) { + return null; + } + + ++$index; + $index = $this->skip_ignored_tokens( $index, $end_index ); + + if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_COLON !== $this->tokens[ $index ]['type'] ) { + return null; + } + + $colon_token = $this->tokens[ $index ]; + $value_start_index = $this->skip_ignored_tokens( $index + 1, $end_index ); + $value_end_index = $this->trim_ignored_tokens( $value_start_index, $end_index ); + $important = false; + $top_level_tokens = $this->get_top_level_non_ignored_token_indexes( $value_start_index, $value_end_index ); + $top_level_count = count( $top_level_tokens ); + $last_value_index = $top_level_count > 0 ? $top_level_tokens[ $top_level_count - 1 ] : null; + $before_last_index = $top_level_count > 1 ? $top_level_tokens[ $top_level_count - 2 ] : null; + + if ( + null !== $last_value_index && + null !== $before_last_index && + WP_CSS_Token_Processor::TOKEN_IDENT === $this->tokens[ $last_value_index ]['type'] && + 0 === strcasecmp( 'important', (string) $this->tokens[ $last_value_index ]['value'] ) && + WP_CSS_Token_Processor::TOKEN_DELIM === $this->tokens[ $before_last_index ]['type'] && + '!' === $this->tokens[ $before_last_index ]['value'] + ) { + $important = true; + $value_end_index = $this->trim_ignored_tokens( $value_start_index, $before_last_index ); + } + + if ( $value_start_index >= $value_end_index ) { + $value_start = $colon_token['end']; + $value_end = $colon_token['end']; + } else { + $value_start = $this->tokens[ $value_start_index ]['start']; + $value_end = $this->tokens[ $value_end_index - 1 ]['end']; + } + + return array( + 'name' => $name, + 'raw_name' => substr( $this->style, $name_token['start'], $name_token['length'] ), + 'leading_start' => $leading_start, + 'start' => $name_token['start'], + 'after' => $after, + 'trailing_end' => $after, + 'value_start' => $value_start, + 'value_end' => $value_end, + 'important' => $important, + ); + } + + /** + * Gets top-level non-ignored token indexes from a token range. + * + * @param int $start Start token index. + * @param int $end End token index. + * @return int[] Top-level non-ignored token indexes. + */ + private function get_top_level_non_ignored_token_indexes( int $start, int $end ): array { + $top_level_tokens = array(); + $index = $start; + + while ( $index < $end ) { + $token = $this->tokens[ $index ]; + + if ( $this->is_ignored_token( $token ) ) { + ++$index; + continue; + } + + $top_level_tokens[] = $index; + + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); + continue; + } + + ++$index; + } + + return $top_level_tokens; + } + + /** + * Skips ignored CSS tokens. + * + * @param int $index Token index. + * @param int|null $end Optional token index at which to stop. + * @return int Token index after ignored tokens. + */ + private function skip_ignored_tokens( int $index, ?int $end = null ): int { + $end = null === $end ? count( $this->tokens ) : $end; + + while ( $index < $end && $this->is_ignored_token( $this->tokens[ $index ] ) ) { + ++$index; + } + + return $index; + } + + /** + * Trims ignored CSS tokens from the end of a token range. + * + * @param int $start Start token index. + * @param int $end End token index. + * @return int Trimmed end token index. + */ + private function trim_ignored_tokens( int $start, int $end ): int { + while ( $end > $start && $this->is_ignored_token( $this->tokens[ $end - 1 ] ) ) { + --$end; + } + + return $end; + } + + /** + * Gets the byte offset after ignored tokens beginning at a token index. + * + * @param int $index Token index. + * @return int Byte offset after ignored tokens. + */ + private function get_trailing_ignored_end( int $index ): int { + $end = $index > 0 && isset( $this->tokens[ $index - 1 ] ) ? $this->tokens[ $index - 1 ]['end'] : 0; + $count = count( $this->tokens ); + + while ( $index < $count && $this->is_ignored_token( $this->tokens[ $index ] ) ) { + $end = $this->tokens[ $index ]['end']; + ++$index; + } + + return $end; + } + + /** + * Checks whether a token is ignored by declaration-list grammar. + * + * @param array{type:string, value:string|null, start:int, length:int, end:int} $token Token metadata. + * @return bool Whether the token is ignored. + */ + private function is_ignored_token( array $token ): bool { + return ( + WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token['type'] || + WP_CSS_Token_Processor::TOKEN_COMMENT === $token['type'] + ); + } + + /** + * Gets the current declaration metadata. + * + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool}|null + */ + private function get_current_declaration(): ?array { + if ( $this->current_declaration < 0 || ! isset( $this->declarations[ $this->current_declaration ] ) ) { + return null; + } + + return $this->declarations[ $this->current_declaration ]; + } + + /** + * Queues a lexical update, replacing any earlier update for the same declaration. + * + * @param int $start Byte offset at which to start the update. + * @param int $length Number of bytes to replace. + * @param string $text Replacement text. + * @param int|null $declaration Declaration index associated with the update. + */ + private function queue_lexical_update( int $start, int $length, string $text, ?int $declaration ): void { + if ( null !== $declaration ) { + foreach ( $this->lexical_updates as $index => $update ) { + if ( $update['declaration'] === $declaration ) { + unset( $this->lexical_updates[ $index ] ); + } + } + } + + $this->lexical_updates[] = array( + 'start' => $start, + 'length' => $length, + 'text' => $text, + 'declaration' => $declaration, + 'order' => $this->lexical_update_order++, + ); + } + + /** + * Checks whether an append has already been queued at a byte offset. + * + * @param int $insert_at Byte offset. + * @return bool Whether an append has already been queued at the offset. + */ + private function has_queued_append_at( int $insert_at ): bool { + foreach ( $this->lexical_updates as $update ) { + if ( null === $update['declaration'] && $insert_at === $update['start'] && 0 === $update['length'] ) { + return true; + } + } + + return false; + } + + /** + * Merges overlapping lexical updates into non-overlapping replacements. + * + * Adjacent declaration removals may share separator whitespace, and appends + * may be queued inside a range removed from the original style. This method + * collapses those updates while preserving the requested replacement text. + * + * @param array $updates Lexical updates. + * @return array Merged updates. + */ + private function merge_overlapping_lexical_updates( array $updates ): array { + $merged = array(); + + foreach ( $updates as $update ) { + if ( empty( $merged ) ) { + $merged[] = $update; + continue; + } + + $last_index = count( $merged ) - 1; + $last = $merged[ $last_index ]; + $last_end = $last['start'] + $last['length']; + $update_end = $update['start'] + $update['length']; + + if ( $update['start'] > $last_end ) { + $merged[] = $update; + continue; + } + + $text = $update['text']; + if ( 0 === $last['start'] && '' === $last['text'] && 0 === $update['length'] ) { + $text = ltrim( $text, ';' . self::WHITESPACE ); + } + + $merged[ $last_index ]['length'] = max( $last_end, $update_end ) - $last['start']; + $merged[ $last_index ]['text'] = $last['text'] . $text; + } + + foreach ( $merged as $index => $update ) { + if ( 0 !== $update['start'] || '' !== $update['text'] ) { + continue; + } + + $end = $update['length']; + while ( $end < strlen( $this->style ) && false !== strpos( self::WHITESPACE, $this->style[ $end ] ) ) { + ++$end; + } + + $merged[ $index ]['length'] = $end; + } + + return $merged; + } + + /** + * Serializes a declaration. + * + * @param string $property_name CSS property name. + * @param string $value CSS declaration value. + * @param bool $important Whether to append !important. + * @return string Serialized declaration. + */ + private function serialize_declaration( string $property_name, string $value, bool $important ): string { + $value = trim( $value, self::WHITESPACE ); + return $property_name . ': ' . $value . ( $important ? ' !important' : '' ) . ';'; + } + + /** + * Checks whether two property names match. + * + * @param string $actual Property name from a declaration. + * @param string $query Queried property name. + * @return bool Whether the property names match. + */ + private function matches_property_name( string $actual, string $query ): bool { + if ( $this->is_custom_property_name( $actual ) || $this->is_custom_property_name( $query ) ) { + return $actual === $query; + } + + return 0 === strcasecmp( $actual, $query ); + } + + /** + * Checks whether a property name is a custom property. + * + * @param string $property_name Property name. + * @return bool Whether this is a custom property name. + */ + private function is_custom_property_name( string $property_name ): bool { + return strlen( $property_name ) >= 2 && '--' === substr( $property_name, 0, 2 ); + } + + /** + * Checks whether a CSS property name is syntactically valid. + * + * @param string $property_name CSS property name. + * @return bool Whether the property name is valid. + */ + private function is_valid_property_name( string $property_name ): bool { + $processor = WP_CSS_Token_Processor::create( $property_name ); + if ( null === $processor || ! $processor->next_token() ) { + return false; + } + + if ( + WP_CSS_Token_Processor::TOKEN_IDENT !== $processor->get_token_type() || + $processor->get_token_start() !== 0 || + $processor->get_token_length() !== strlen( $property_name ) + ) { + return false; + } + + if ( $processor->next_token() ) { + return false; + } + + $sentinel_processor = WP_CSS_Token_Processor::create( $property_name . ': sentinel' ); + if ( null === $sentinel_processor || ! $sentinel_processor->next_token() ) { + return false; + } + + if ( + WP_CSS_Token_Processor::TOKEN_IDENT !== $sentinel_processor->get_token_type() || + $sentinel_processor->get_token_start() !== 0 || + $sentinel_processor->get_token_length() !== strlen( $property_name ) + ) { + return false; + } + + return ( + $sentinel_processor->next_token() && + WP_CSS_Token_Processor::TOKEN_COLON === $sentinel_processor->get_token_type() + ); + } + + /** + * Checks whether a CSS declaration value is syntactically safe to insert. + * + * @param string $value CSS declaration value. + * @return bool Whether the value is valid. + */ + private function is_valid_declaration_value( string $value ): bool { + $sentinel_property = '--wp-style-attribute-processor-sentinel'; + $processor = WP_CSS_Token_Processor::create( $value . ';' . $sentinel_property . ':1' ); + if ( null === $processor ) { + return false; + } + + $stack = array(); + $top_level_tokens = array(); + $found_sentinel = false; + + while ( $processor->next_token() ) { + $type = $processor->get_token_type(); + + if ( empty( $stack ) && WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $type && WP_CSS_Token_Processor::TOKEN_COMMENT !== $type ) { + if ( WP_CSS_Token_Processor::TOKEN_SEMICOLON === $type ) { + if ( strlen( $value ) !== $processor->get_token_start() ) { + return false; + } + + $found_sentinel = true; + break; + } + + $top_level_tokens[] = array( + 'type' => $type, + 'value' => $processor->get_token_value(), + ); + } + + switch ( $type ) { + case WP_CSS_Token_Processor::TOKEN_SEMICOLON: + if ( empty( $stack ) ) { + return false; + } + break; + + case WP_CSS_Token_Processor::TOKEN_BAD_STRING: + case WP_CSS_Token_Processor::TOKEN_BAD_URL: + return false; + + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: + $stack[] = WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN; + break; + + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: + $stack[] = WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET; + break; + + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + $stack[] = WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE; + break; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: + if ( empty( $stack ) || array_pop( $stack ) !== $processor->get_token_type() ) { + return false; + } + break; + } + } + + if ( ! $found_sentinel || ! empty( $stack ) ) { + return false; + } + + $top_level_count = count( $top_level_tokens ); + if ( $top_level_count < 2 ) { + return true; + } + + $last_value_token = $top_level_tokens[ $top_level_count - 1 ]; + $before_last_token = $top_level_tokens[ $top_level_count - 2 ]; + + return ! ( + WP_CSS_Token_Processor::TOKEN_IDENT === $last_value_token['type'] && + 0 === strcasecmp( 'important', (string) $last_value_token['value'] ) && + WP_CSS_Token_Processor::TOKEN_DELIM === $before_last_token['type'] && + '!' === $before_last_token['value'] + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index db51bd5fcb2df..0710d653e9acb 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -280,6 +280,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; require ABSPATH . WPINC . '/css-api/class-wp-css-builder.php'; require ABSPATH . WPINC . '/css-api/class-wp-css-token-processor.php'; +require ABSPATH . WPINC . '/html-api/class-wp-html-style-attribute-processor.php'; require ABSPATH . WPINC . '/class-wp-block-processor.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php new file mode 100644 index 0000000000000..45c30287ed555 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -0,0 +1,289 @@ +assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertSame( 'red', $processor->get_value() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertSame( 'color(display-p3 1 0 0)', $processor->get_value() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'blue', $processor->get_value() ); + + $this->assertFalse( $processor->next_declaration() ); + } + + /** + * @covers ::next_declaration + */ + public function test_next_declaration_query_visits_matching_duplicate_properties() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white; COLOR: blue;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'red', $processor->get_value() ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'blue', $processor->get_value() ); + + $this->assertFalse( $processor->next_declaration( 'color' ) ); + + $processor = new WP_HTML_Style_Attribute_Processor( '--Tone: warm; --tone: cool;' ); + + $this->assertTrue( $processor->next_declaration( '--tone' ) ); + $this->assertSame( 'cool', $processor->get_value() ); + + $this->assertFalse( $processor->next_declaration( '--tone' ) ); + } + + /** + * @covers ::is_important + * @covers ::get_value + */ + public function test_important_priority_is_inspected_separately_from_value() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red !important; --tone: blue !IMPORTANT; background: white;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'red', $processor->get_value() ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'blue', $processor->get_value() ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'white', $processor->get_value() ); + $this->assertFalse( $processor->is_important() ); + } + + /** + * @covers ::set_value + * @covers ::get_updated_style + */ + public function test_set_value_updates_current_declaration_without_collapsing_duplicates() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red !important; color: color(display-p3 1 0 0);' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green', false ) ); + + $this->assertSame( 'color: green; color: color(display-p3 1 0 0);', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_removes_only_current_duplicate() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_removes_adjacent_duplicate_declarations() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' ); + + while ( $processor->next_declaration( 'color' ) ) { + $this->assertTrue( $processor->remove_declaration() ); + } + + $this->assertSame( 'background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_adds_duplicate_declaration() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'color(display-p3 1 0 0)', true ) ); + + $this->assertSame( 'color: red; color: color(display-p3 1 0 0) !important;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_preserves_multiple_appends_in_order() { + $processor = new WP_HTML_Style_Attribute_Processor( '' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_after_removing_only_declaration() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertSame( 'background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::next_declaration + * @covers ::get_updated_style + */ + public function test_invalid_fragments_are_skipped_and_preserved() { + $style = 'color red; @media (min-width: 1px) { color: green; } background: white;'; + $processor = new WP_HTML_Style_Attribute_Processor( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'white', $processor->get_value() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + + /** + * @covers ::next_declaration + * @covers ::get_value + */ + public function test_values_can_contain_semicolons_inside_component_values() { + $processor = new WP_HTML_Style_Attribute_Processor( 'background: image-set(url("a;b.png") 1x); color: red;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'image-set(url("a;b.png") 1x)', $processor->get_value() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::is_important + * @covers ::get_value + */ + public function test_important_inside_unclosed_function_is_not_declaration_priority() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x, red !important' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'var(--x, red !important', $processor->get_value() ); + $this->assertFalse( $processor->is_important() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_declaration_values_reject_top_level_semicolons() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + + $this->assertFalse( $processor->append_declaration( 'background', 'blue; color: green' ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_declaration_values_reject_top_level_important_priority() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( 'green ! important', false ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'white !important' ) ); + + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_malformed_declaration_values + * + * @param string $value Malformed CSS declaration value. + */ + public function test_declaration_values_reject_malformed_eof_tokens( string $value ) { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + + $this->assertFalse( $processor->append_declaration( 'background', $value ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::get_updated_style + */ + public function test_updated_style_can_be_set_on_html_tag_processor() { + $tags = new WP_HTML_Tag_Processor( '
Text
' ); + $this->assertTrue( $tags->next_tag( 'div' ) ); + + $styles = new WP_HTML_Style_Attribute_Processor( $tags->get_attribute( 'style' ) ); + $this->assertTrue( $styles->append_declaration( 'background', 'white' ) ); + $this->assertTrue( $tags->set_attribute( 'style', $styles->get_updated_style() ) ); + + $this->assertSame( + '
Text
', + $tags->get_updated_html() + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_malformed_declaration_values(): array { + return array( + 'eof string' => array( '"unterminated' ), + 'eof string escaped end' => array( '"unterminated\\' ), + 'eof string apparent end escaped' => array( '"unterminated\\"' ), + 'eof comment' => array( '/*' ), + 'eof url' => array( 'url(foo' ), + 'eof url escaped end' => array( 'url(foo\\' ), + 'eof url apparent end escaped' => array( 'url(foo\\)' ), + 'eof escape' => array( 'red\\' ), + ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_property_names_reject_eof_escapes() { + $processor = new WP_HTML_Style_Attribute_Processor( '' ); + + $this->assertFalse( $processor->append_declaration( 'color\\', 'red' ) ); + $this->assertSame( '', $processor->get_updated_style() ); + } +} From 6df7ef1366645adcf7f0d7d1f370a571e7bc3021 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:34:12 +0200 Subject: [PATCH 02/10] Harden style attribute parsing --- ...lass-wp-html-style-attribute-processor.php | 16 +-- .../wpHtmlStyleAttributeProcessor.php | 106 ++++++++++++++++++ 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index 8c76fca09c7dc..28fe974678040 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -74,9 +74,15 @@ class WP_HTML_Style_Attribute_Processor { /** * Constructor. * - * @param string $style Decoded style attribute value. + * @param string|bool|null $style Decoded style attribute value. */ - public function __construct( string $style ) { + public function __construct( $style = '' ) { + if ( null === $style || true === $style ) { + $style = ''; + } elseif ( ! is_string( $style ) ) { + $style = ''; + } + $this->style = $style; $this->parse(); } @@ -326,12 +332,6 @@ private function parse_declaration_list(): void { continue; } - if ( WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE === $this->tokens[ $index ]['type'] ) { - $previous_item_ends_at = $this->tokens[ $index ]['end']; - ++$index; - continue; - } - list( $declaration_end, $after_declaration, $has_semicolon ) = $this->consume_declaration_segment( $index ); $declaration = $this->parse_declaration_segment( $leading_start, $index, $declaration_end, $after_declaration ); diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 45c30287ed555..fcc51bee7ee5d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -12,6 +12,33 @@ * @coversDefaultClass WP_HTML_Style_Attribute_Processor */ class Tests_HtmlApi_WpHtmlStyleAttributeProcessor extends WP_UnitTestCase { + /** + * @covers ::__construct + * @covers ::next_declaration + * @covers ::get_updated_style + * + * @dataProvider data_html_style_attribute_values + * + * @param string $html HTML containing a first div. + * @param string $expected_style Expected normalized style input. + * @param string|null $property_name Expected first property name. + */ + public function test_constructor_accepts_html_tag_processor_style_attribute_values( string $html, string $expected_style, ?string $property_name ) { + $tags = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $tags->next_tag( 'div' ) ); + + $processor = new WP_HTML_Style_Attribute_Processor( $tags->get_attribute( 'style' ) ); + + $this->assertSame( $expected_style, $processor->get_updated_style() ); + + if ( null === $property_name ) { + $this->assertFalse( $processor->next_declaration() ); + } else { + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $property_name, $processor->get_property_name() ); + } + } + /** * @covers ::next_declaration * @covers ::get_property_name @@ -173,6 +200,18 @@ public function test_invalid_fragments_are_skipped_and_preserved() { $this->assertSame( $style, $processor->get_updated_style() ); } + /** + * @covers ::next_declaration + */ + public function test_stray_right_brace_consumes_bad_declaration_until_semicolon() { + $processor = new WP_HTML_Style_Attribute_Processor( '} color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'white', $processor->get_value() ); + $this->assertFalse( $processor->next_declaration() ); + } + /** * @covers ::next_declaration * @covers ::get_value @@ -188,6 +227,26 @@ public function test_values_can_contain_semicolons_inside_component_values() { $this->assertSame( 'color', $processor->get_property_name() ); } + /** + * @covers ::next_declaration + * @covers ::set_value + * @covers ::get_updated_style + */ + public function test_escaped_property_names_match_decoded_names_and_preserve_raw_spelling() { + $processor = new WP_HTML_Style_Attribute_Processor( 'c\\6f lor: red; --t\\6f ne: cool; --Tone: warm;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertSame( 'c\\6f lor: green; --t\\6f ne: cool; --Tone: warm;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->next_declaration( '--tone' ) ); + $this->assertSame( '--tone', $processor->get_property_name() ); + $this->assertSame( 'cool', $processor->get_value() ); + + $this->assertFalse( $processor->next_declaration( '--tone' ) ); + } + /** * @covers ::is_important * @covers ::get_value @@ -200,6 +259,24 @@ public function test_important_inside_unclosed_function_is_not_declaration_prior $this->assertFalse( $processor->is_important() ); } + /** + * @covers ::is_important + * @covers ::get_value + * + * @dataProvider data_important_priority_syntax + * + * @param string $style Style attribute value. + * @param string $value Expected declaration value. + * @param bool $important Expected importance. + */ + public function test_important_priority_syntax_variants( string $style, string $value, bool $important ) { + $processor = new WP_HTML_Style_Attribute_Processor( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $value, $processor->get_value() ); + $this->assertSame( $important, $processor->is_important() ); + } + /** * @covers ::append_declaration * @covers ::get_updated_style @@ -258,6 +335,35 @@ public function test_updated_style_can_be_set_on_html_tag_processor() { ); } + /** + * Data provider. + * + * @return array + */ + public static function data_html_style_attribute_values(): array { + return array( + 'missing style attribute' => array( '
Text
', '', null ), + 'boolean style attribute' => array( '
Text
', '', null ), + 'empty style attribute' => array( '
Text
', '', null ), + 'valued style attribute' => array( '
Text
', 'color: red', 'color' ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_important_priority_syntax(): array { + return array( + 'no whitespace' => array( 'color: red!important;', 'red', true ), + 'whitespace after bang' => array( 'color: red ! important;', 'red', true ), + 'comment after bang' => array( 'color: red ! /*x*/ important;', 'red', true ), + 'escaped important' => array( 'color: red !\\69mportant;', 'red', true ), + 'extra trailing token' => array( 'color: red ! important foo;', 'red ! important foo', false ), + ); + } + /** * Data provider. * From 002626e2e4014809b749c456dd28da085a074a5e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:49:50 +0200 Subject: [PATCH 03/10] Refresh style declarations after mutation --- ...lass-wp-html-style-attribute-processor.php | 63 ++++++++++++++-- .../wpHtmlStyleAttributeProcessor.php | 74 +++++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index 28fe974678040..803fc431799c5 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -57,6 +57,13 @@ class WP_HTML_Style_Attribute_Processor { */ private $current_declaration = -1; + /** + * Whether the current declaration was removed and no new declaration has been selected. + * + * @var bool + */ + private $current_declaration_removed = false; + /** * Lexical updates to apply to the original style attribute value. * @@ -102,12 +109,14 @@ public function next_declaration( ?string $property_name = null ): bool { null === $property_name || $this->matches_property_name( $this->declarations[ $i ]['name'], $property_name ) ) { - $this->current_declaration = $i; + $this->current_declaration = $i; + $this->current_declaration_removed = false; return true; } } - $this->current_declaration = count( $this->declarations ); + $this->current_declaration = count( $this->declarations ); + $this->current_declaration_removed = false; return false; } @@ -174,6 +183,8 @@ public function set_value( string $value, ?bool $important = null ): bool { $this->current_declaration ); + $this->apply_lexical_updates( $this->current_declaration ); + return true; } @@ -206,6 +217,8 @@ public function remove_declaration(): bool { $this->current_declaration ); + $this->apply_lexical_updates( $this->current_declaration - 1, true ); + return true; } @@ -225,9 +238,10 @@ public function append_declaration( string $property_name, string $value, bool $ return false; } - $trimmed_style = rtrim( $this->style, self::WHITESPACE ); - $insert_at = strlen( $trimmed_style ); - $separator = $this->has_queued_append_at( $insert_at ) ? ' ' : ''; + $old_declaration_count = count( $this->declarations ); + $trimmed_style = rtrim( $this->style, self::WHITESPACE ); + $insert_at = strlen( $trimmed_style ); + $separator = $this->has_queued_append_at( $insert_at ) ? ' ' : ''; if ( '' === $separator && '' !== trim( $trimmed_style, self::WHITESPACE ) ) { $separator = ( ';' === substr( $trimmed_style, -1 ) ) ? ' ' : '; '; @@ -240,6 +254,16 @@ public function append_declaration( string $property_name, string $value, bool $ null ); + $append_after_exhausted_cursor = $this->current_declaration >= $old_declaration_count; + $current_after_append = $append_after_exhausted_cursor + ? $old_declaration_count - 1 + : $this->current_declaration; + + $this->apply_lexical_updates( + $current_after_append, + $this->current_declaration_removed || $append_after_exhausted_cursor + ); + return true; } @@ -662,13 +686,40 @@ private function is_ignored_token( array $token ): bool { * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool}|null */ private function get_current_declaration(): ?array { - if ( $this->current_declaration < 0 || ! isset( $this->declarations[ $this->current_declaration ] ) ) { + if ( + $this->current_declaration_removed || + $this->current_declaration < 0 || + ! isset( $this->declarations[ $this->current_declaration ] ) + ) { return null; } return $this->declarations[ $this->current_declaration ]; } + /** + * Applies queued lexical updates and reparses the updated style attribute value. + * + * @param int $current_declaration Declaration index to keep before the next cursor advance. + * @param bool $current_declaration_removed Optional. Whether the current declaration was removed. + */ + private function apply_lexical_updates( int $current_declaration, bool $current_declaration_removed = false ): void { + $this->style = $this->get_updated_style(); + + $this->tokens = array(); + $this->declarations = array(); + $this->lexical_updates = array(); + $this->lexical_update_order = 0; + $this->current_declaration = $current_declaration; + $this->current_declaration_removed = $current_declaration_removed; + + $this->parse(); + + if ( $this->current_declaration >= count( $this->declarations ) ) { + $this->current_declaration = count( $this->declarations ); + } + } + /** * Queues a lexical update, replacing any earlier update for the same declaration. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index fcc51bee7ee5d..d81dd93fad72f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -117,6 +117,25 @@ public function test_set_value_updates_current_declaration_without_collapsing_du $this->assertSame( 'color: green; color: color(display-p3 1 0 0);', $processor->get_updated_style() ); } + /** + * @covers ::set_value + * @covers ::get_value + * @covers ::is_important + */ + public function test_getters_reflect_current_declaration_after_set_value() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green', true ) ); + + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertSame( 'green', $processor->get_value() ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + /** * @covers ::remove_declaration * @covers ::get_updated_style @@ -131,6 +150,25 @@ public function test_remove_declaration_removes_only_current_duplicate() { $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } + /** + * @covers ::remove_declaration + * @covers ::get_property_name + * @covers ::get_value + */ + public function test_getters_return_null_after_removing_current_declaration_until_cursor_advances() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->get_value() ); + $this->assertFalse( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + /** * @covers ::remove_declaration * @covers ::get_updated_style @@ -170,6 +208,42 @@ public function test_append_declaration_preserves_multiple_appends_in_order() { $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } + /** + * @covers ::append_declaration + * @covers ::next_declaration + */ + public function test_appended_declarations_can_be_inspected_by_the_cursor() { + $processor = new WP_HTML_Style_Attribute_Processor( '' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertSame( 'red', $processor->get_value() ); + } + + /** + * @covers ::append_declaration + * @covers ::next_declaration + * @covers ::get_property_name + */ + public function test_append_declaration_preserves_exhausted_cursor_until_it_advances() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->get_value() ); + $this->assertFalse( $processor->set_value( 'green' ) ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'white', $processor->get_value() ); + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + /** * @covers ::remove_declaration * @covers ::append_declaration From 0f4c7624b9ec8f9fadadc273a39d0ae28d14f731 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 14:14:51 +0200 Subject: [PATCH 04/10] Respect comments when appending style declarations --- ...lass-wp-html-style-attribute-processor.php | 115 +++++++++++++++++- .../wpHtmlStyleAttributeProcessor.php | 33 +++++ 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index 803fc431799c5..ef7bc3a064f49 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -241,16 +241,17 @@ public function append_declaration( string $property_name, string $value, bool $ $old_declaration_count = count( $this->declarations ); $trimmed_style = rtrim( $this->style, self::WHITESPACE ); $insert_at = strlen( $trimmed_style ); - $separator = $this->has_queued_append_at( $insert_at ) ? ' ' : ''; + $separator = $this->get_append_separator( $insert_at ); + $declaration_text = $this->serialize_declaration( $property_name, $value, $important ); - if ( '' === $separator && '' !== trim( $trimmed_style, self::WHITESPACE ) ) { - $separator = ( ';' === substr( $trimmed_style, -1 ) ) ? ' ' : '; '; + if ( ! $this->is_parseable_append( $insert_at, $separator, $declaration_text, $old_declaration_count ) ) { + return false; } $this->queue_lexical_update( $insert_at, 0, - $separator . $this->serialize_declaration( $property_name, $value, $important ), + $separator . $declaration_text, null ); @@ -762,6 +763,112 @@ private function has_queued_append_at( int $insert_at ): bool { return false; } + /** + * Gets the separator to use before an appended declaration. + * + * Comments and whitespace are CSS parser trivia. They do not determine + * whether the existing declaration list needs a semicolon before appending. + * + * @param int $insert_at Byte offset at which the declaration will be inserted. + * @return string Separator text. + */ + private function get_append_separator( int $insert_at ): string { + if ( $this->has_queued_append_at( $insert_at ) ) { + return ' '; + } + + $last_token = $this->get_last_top_level_non_ignored_token_before( $insert_at ); + + if ( null === $last_token ) { + return $insert_at > 0 ? ' ' : ''; + } + + return WP_CSS_Token_Processor::TOKEN_SEMICOLON === $last_token['type'] ? ' ' : '; '; + } + + /** + * Gets the last top-level non-ignored token before a byte offset. + * + * @param int $insert_at Byte offset before which to scan. + * @return array{type:string, value:string|null, start:int, length:int, end:int}|null Token metadata. + */ + private function get_last_top_level_non_ignored_token_before( int $insert_at ): ?array { + $count = count( $this->tokens ); + $index = 0; + $last_token = null; + + while ( $index < $count ) { + $token = $this->tokens[ $index ]; + + if ( $token['start'] >= $insert_at ) { + break; + } + + if ( $this->is_ignored_token( $token ) ) { + ++$index; + continue; + } + + $last_token = $token; + + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); + continue; + } + + ++$index; + } + + return $last_token; + } + + /** + * Checks whether an appended declaration will parse as a new declaration. + * + * Malformed existing declarations can leave the append point inside an + * unclosed component value. In that case preserving the original text and + * appending a top-level declaration are incompatible. + * + * @param int $insert_at Byte offset at which the declaration will be inserted. + * @param string $separator Separator text. + * @param string $declaration_text Serialized declaration text. + * @param int $old_declaration_count Number of declarations before appending. + * @return bool Whether the appended declaration is parseable. + */ + private function is_parseable_append( int $insert_at, string $separator, string $declaration_text, int $old_declaration_count ): bool { + $expected_start = $insert_at + strlen( $separator ); + $expected_after = $expected_start + strlen( $declaration_text ); + $candidate_style = substr( $this->style, 0, $insert_at ) . $separator . $declaration_text . substr( $this->style, $insert_at ); + $candidate = new self( $candidate_style ); + + if ( count( $candidate->declarations ) !== $old_declaration_count + 1 ) { + return false; + } + + foreach ( $candidate->declarations as $declaration ) { + if ( $expected_start === $declaration['start'] && $expected_after === $declaration['after'] ) { + return true; + } + } + + return false; + } + /** * Merges overlapping lexical updates into non-overlapping replacements. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index d81dd93fad72f..d44c4ec271617 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -208,6 +208,39 @@ public function test_append_declaration_preserves_multiple_appends_in_order() { $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_treats_comments_as_trivia_for_separators() { + $processor = new WP_HTML_Style_Attribute_Processor( '/*keep*/' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( '/*keep*/ color: red;', $processor->get_updated_style() ); + + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;/*keep*/' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: red;/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x;/*keep*/);' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: var(--x;/*keep*/); background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_rejects_unclosed_component_value_append_points() { + $style = 'color: var(--x;/*keep*/'; + $processor = new WP_HTML_Style_Attribute_Processor( $style ); + + $this->assertFalse( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + /** * @covers ::append_declaration * @covers ::next_declaration From 0df59a8ad47d0c5d7bd701d9461a6d0cecf5eb27 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 14:22:43 +0200 Subject: [PATCH 05/10] Preserve comments when removing style declarations --- ...lass-wp-html-style-attribute-processor.php | 42 +++++++++++++++---- .../wpHtmlStyleAttributeProcessor.php | 24 +++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index ef7bc3a064f49..46e2158aee8e8 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -202,18 +202,14 @@ public function remove_declaration(): bool { return false; } - if ( '' === trim( substr( $this->style, 0, $declaration['leading_start'] ), self::WHITESPACE ) ) { - $remove_start = $declaration['leading_start']; - $remove_end = $declaration['trailing_end']; - } else { - $remove_start = $declaration['leading_start']; - $remove_end = $declaration['after']; - } + $remove_start = $this->get_offset_before_preceding_whitespace( $declaration['start'] ); + $remove_end = $this->get_offset_after_following_whitespace( $declaration['after'] ); + $replacement = ( $remove_start > 0 && $remove_end < strlen( $this->style ) ) ? ' ' : ''; $this->queue_lexical_update( $remove_start, $remove_end - $remove_start, - '', + $replacement, $this->current_declaration ); @@ -681,6 +677,36 @@ private function is_ignored_token( array $token ): bool { ); } + /** + * Gets the byte offset before contiguous whitespace ending at an offset. + * + * @param int $offset Byte offset. + * @return int Offset before preceding whitespace. + */ + private function get_offset_before_preceding_whitespace( int $offset ): int { + while ( $offset > 0 && false !== strpos( self::WHITESPACE, $this->style[ $offset - 1 ] ) ) { + --$offset; + } + + return $offset; + } + + /** + * Gets the byte offset after contiguous whitespace beginning at an offset. + * + * @param int $offset Byte offset. + * @return int Offset after following whitespace. + */ + private function get_offset_after_following_whitespace( int $offset ): int { + $length = strlen( $this->style ); + + while ( $offset < $length && false !== strpos( self::WHITESPACE, $this->style[ $offset ] ) ) { + ++$offset; + } + + return $offset; + } + /** * Gets the current declaration metadata. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index d44c4ec271617..3bf4867f176d9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -183,6 +183,30 @@ public function test_remove_declaration_removes_adjacent_duplicate_declarations( $this->assertSame( 'background: white;', $processor->get_updated_style() ); } + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_preserves_surrounding_comments() { + $processor = new WP_HTML_Style_Attribute_Processor( '/*keep*/ color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( 'color: red; /*keep*/', $processor->get_updated_style() ); + } + /** * @covers ::append_declaration * @covers ::get_updated_style From 595d554df85a8f67f022d9ae13257910f9b21df3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 16:20:24 +0200 Subject: [PATCH 06/10] Lazily parse style attribute declarations --- ...lass-wp-html-style-attribute-processor.php | 53 +++++++++++++++---- .../wpHtmlStyleAttributeProcessor.php | 11 ++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index 46e2158aee8e8..868934202c8fb 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -10,10 +10,15 @@ /** * Core class used to inspect and modify CSS declarations in an HTML style attribute. * - * The processor operates on decoded style attribute values. Values read from - * {@see WP_HTML_Tag_Processor::get_attribute()} can be passed directly to this - * class, and values returned by {@see WP_HTML_Style_Attribute_Processor::get_updated_style()} - * can be passed back to {@see WP_HTML_Tag_Processor::set_attribute()}. + * The processor operates on the decoded CSS text value of a style attribute: + * the CSS declaration-list contents of a declaration block, excluding the + * delimiting braces. It does not parse HTML attribute syntax, decode character + * references, or operate on raw HTML markup. + * + * Values read from {@see WP_HTML_Tag_Processor::get_attribute()} can be passed + * directly to this class, and values returned by + * {@see WP_HTML_Style_Attribute_Processor::get_updated_style()} can be passed + * back to {@see WP_HTML_Tag_Processor::set_attribute()}. * * Unlike associative-array based style helpers, this processor preserves the * declaration list model of CSS. Multiple declarations with the same property @@ -50,6 +55,13 @@ class WP_HTML_Style_Attribute_Processor { */ private $declarations = array(); + /** + * Whether the style attribute value has been parsed. + * + * @var bool + */ + private $parsed = false; + /** * Index of the current declaration, or -1 before the first declaration. * @@ -81,7 +93,7 @@ class WP_HTML_Style_Attribute_Processor { /** * Constructor. * - * @param string|bool|null $style Decoded style attribute value. + * @param string|bool|null $style Decoded CSS text value from a style attribute. */ public function __construct( $style = '' ) { if ( null === $style || true === $style ) { @@ -91,7 +103,6 @@ public function __construct( $style = '' ) { } $this->style = $style; - $this->parse(); } /** @@ -100,10 +111,12 @@ public function __construct( $style = '' ) { * When a property name is provided, normal CSS properties are matched * ASCII-case-insensitively while custom properties are matched exactly. * - * @param string|null $property_name Optional property name to match. + * @param string|null $property_name Optional declaration property name to match. * @return bool Whether a matching declaration was found. */ public function next_declaration( ?string $property_name = null ): bool { + $this->ensure_parsed(); + for ( $i = $this->current_declaration + 1; $i < count( $this->declarations ); $i++ ) { if ( null === $property_name || @@ -234,6 +247,8 @@ public function append_declaration( string $property_name, string $value, bool $ return false; } + $this->ensure_parsed(); + $old_declaration_count = count( $this->declarations ); $trimmed_style = rtrim( $this->style, self::WHITESPACE ); $insert_at = strlen( $trimmed_style ); @@ -265,9 +280,9 @@ public function append_declaration( string $property_name, string $value, bool $ } /** - * Returns the updated style attribute value. + * Returns the updated decoded CSS text value for the style attribute. * - * @return string Updated style attribute value. + * @return string Updated decoded CSS text value for the style attribute. */ public function get_updated_style(): string { if ( empty( $this->lexical_updates ) ) { @@ -305,8 +320,12 @@ static function ( $a, $b ) { * Parses CSS tokens and declarations from the style attribute value. */ private function parse(): void { + $this->tokens = array(); + $this->declarations = array(); + $processor = WP_CSS_Token_Processor::create( $this->style ); if ( null === $processor ) { + $this->parsed = true; return; } @@ -323,6 +342,18 @@ private function parse(): void { } $this->parse_declaration_list(); + $this->parsed = true; + } + + /** + * Lazily parses the style attribute value when declaration access needs it. + */ + private function ensure_parsed(): void { + if ( $this->parsed ) { + return; + } + + $this->parse(); } /** @@ -733,12 +764,11 @@ private function get_current_declaration(): ?array { private function apply_lexical_updates( int $current_declaration, bool $current_declaration_removed = false ): void { $this->style = $this->get_updated_style(); - $this->tokens = array(); - $this->declarations = array(); $this->lexical_updates = array(); $this->lexical_update_order = 0; $this->current_declaration = $current_declaration; $this->current_declaration_removed = $current_declaration_removed; + $this->parsed = false; $this->parse(); @@ -881,6 +911,7 @@ private function is_parseable_append( int $insert_at, string $separator, string $expected_after = $expected_start + strlen( $declaration_text ); $candidate_style = substr( $this->style, 0, $insert_at ) . $separator . $declaration_text . substr( $this->style, $insert_at ); $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); if ( count( $candidate->declarations ) !== $old_declaration_count + 1 ) { return false; diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 3bf4867f176d9..3e7a235578631 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -84,6 +84,17 @@ public function test_next_declaration_query_visits_matching_duplicate_properties $this->assertFalse( $processor->next_declaration( '--tone' ) ); } + /** + * @covers ::next_declaration + */ + public function test_next_declaration_accepts_named_property_name_argument() { + $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( property_name: 'background' ) ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'white', $processor->get_value() ); + } + /** * @covers ::is_important * @covers ::get_value From 10f6e742333e1124a430915edcf07fa06bae0aa2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 00:42:51 +0200 Subject: [PATCH 07/10] Document style attribute processor contract --- .../wp-html-style-attribute-processor-spec.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/wp-html-style-attribute-processor-spec.md diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md new file mode 100644 index 0000000000000..c1d23cfe7c223 --- /dev/null +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -0,0 +1,281 @@ +# WP_HTML_Style_Attribute_Processor Specification + +## Purpose + +`WP_HTML_Style_Attribute_Processor` inspects and modifies decoded CSS text from an +HTML `style` attribute. Its input is the declaration-list contents of the +attribute, excluding HTML attribute syntax, entity decoding, and any surrounding +declaration-block braces. + +The processor preserves the CSS declaration-list model. Duplicate declarations +remain distinct. Invalid CSS fragments are skipped or preserved according to CSS +parser semantics unless a requested mutation cannot be proven safe. + +The processor's north star is: + +1. preserve CSS structure; +2. preserve user intent; +3. preserve the semantic CSS value of any existing declaration it rewrites; +4. make minimal edits when doing so does not compromise the first three goals. + +## Construction + +Creation uses: + +```php +WP_HTML_Style_Attribute_Processor::create( string $decoded_css_text ): static +``` + +The constructor is private. `create()` always returns a processor instance for a +string input. + +Callers must not pass raw `WP_HTML_Tag_Processor::get_attribute( 'style' )` +results directly unless they have already checked that the result is a string. +Missing or boolean attributes are an HTML-layer concern, not accepted CSS text. + +Parsing is lazy. Creating a processor does not scan the style text until cursor +movement, mutation validation, or serialization requires it. + +## Declaration Model + +The processor models each CSS declaration as: + +```text +property-name: component-value-list !important? +``` + +The internal declaration data consists of: + +- decoded property name; +- authored property-name source when available; +- value component list source range; +- `!important` flag; +- nullable source range for the parsed priority span; +- source ranges needed for safe mutation and verification. + +`!important` is not part of the value, including for custom properties. + +## Public API + +Initial public methods: + +```php +public static function create( string $decoded_css_text ): static; +public function next_declaration( ?string $property_name = null ): bool; +public function get_property_name(): ?string; +public function is_important(): ?bool; +public function set_value( string $value, ?bool $important = null ): bool; +public function set_important( bool $important ): bool; +public function remove_declaration(): bool; +public function append_declaration( string $property_name, string $value, bool $important = false ): bool; +public function get_updated_style(): string; +``` + +The first API does not expose `get_value()` or `get_raw_value()`. Raw CSS values +are difficult for callers to use safely, and a higher-level value API needs a +separate design. + +Bookmarks, seek, and rewind are out of scope. Callers that need to rescan should +create a new processor from the updated style text. + +## Cursor Semantics + +`next_declaration()` advances a forward-only cursor over CSS declarations. + +All current-declaration getters return `null` when the cursor is not positioned +on a valid declaration. This includes `is_important()`. + +After `remove_declaration()` succeeds, the cursor becomes invalid until +`next_declaration()` advances it. A second removal on the same invalid cursor +returns `false`. + +After `set_value()` or `set_important()` succeeds without removing the +declaration, the cursor remains on the same logical declaration. + +`set_value( '' )` removes the current declaration. On success it has the same +cursor semantics as `remove_declaration()`. + +`append_declaration()` does not move the cursor. If the cursor was exhausted, it +remains invalid until a later `next_declaration()` advances to the appended +declaration. + +`next_declaration()` flushes pending safe edits before advancing because +advancement must reflect the updated declaration list. + +Current-position getters should reflect successful pending edits without forcing +a full reparse where practical, following HTML API style. + +## Property Names + +All public property-name arguments are decoded/plaintext CSS property names, not +CSS-escaped identifier source. + +Ordinary properties match ASCII case-insensitively. Custom properties match +exactly because casing is semantic. + +`get_property_name()` exposes CSS semantics: + +- ordinary property names are returned lowercase; +- custom property names preserve exact decoded casing. + +Edits preserve authored property-name spelling when the declaration already +exists. Appended ordinary properties serialize lowercase because there is no +authored spelling to preserve. Appended custom properties preserve exact casing. + +Appended property names must be valid plaintext CSS property names. The processor +does not escape arbitrary strings into identifiers. Custom properties follow the +same validity rule with the required `--` prefix. + +## Value Validation + +Mutation inputs accept CSS declaration values, not declaration-list text. + +The supplied value must fit safely in both of these declaration slots, depending +on the property being changed: + +```css +foo: ; +--foo: ; +``` + +Validation checks structure and CSS syntax safety, not property-specific browser +support. The processor should not maintain a browser-style matrix of supported +properties and value grammars. + +Values must be complete and self-contained. Reject inputs that: + +- are empty after CSS whitespace trimming, except `set_value( '' )`, which + removes the current declaration; +- contain top-level declaration separators such as semicolons; +- contain a top-level `!important`, because priority is a separate argument; +- contain bad strings or bad URLs; +- contain unmatched delimiters or constructs that are valid only because CSS + closes them at EOF; +- cannot be proven to occupy only the declaration value slot. + +Nested semicolons and `!important` text inside balanced blocks, functions, +strings, or URLs are allowed when the tokenizer and parser say they are part of +the value. + +## Importance + +`set_value( $value, null )` preserves the existing important flag. +`set_value( $value, true )` sets it. +`set_value( $value, false )` clears it. + +`append_declaration()` receives importance as a separate boolean and rejects a +top-level `!important` in `$value`. + +`set_important()` changes only declaration priority. + +When clearing importance, the processor may remove only the parsed priority span +when that is safe. If minimal removal is unsafe, it may normalize the current +declaration as long as it preserves CSS structure and the existing value's +semantics. If that cannot be proven, it returns `false`. + +When setting importance, the processor adds canonical ` !important` at the +parsed value end or normalizes the declaration if necessary. It must verify that +the updated declaration reparses as important. + +## Mutation Atomicity And Verification + +Invalid or inapplicable mutations return `false` without `_doing_it_wrong()`. + +Failed mutations are atomic: style text, cursor state, and previous successful +edits are preserved. + +A mutation returning `true` guarantees that `get_updated_style()` includes it. + +Every successful mutation must preserve the expected declaration-list structure. +`set_value()` must not let an input value merge, split, hide, or otherwise change +declarations beyond the intended current declaration. + +The public contract describes guarantees, not the exact validation mechanism. +Synthetic declarations, parser comparisons, token-level checks, and reparsing are +implementation details. + +Multiple mutations to the same logical declaration collapse to the final +intended state. Mutations to different declarations in one pass are allowed when +each mutation can be independently verified against the updated declaration +list. + +## Formatting And Normalization + +The processor preserves surrounding text, comments, ignored invalid fragments, +and authored spelling when cheap and safe. + +Minimal edits are a lower priority than safety, correctness, preserved CSS +structure, and semantic preservation. Any normalization required to satisfy +those higher priorities is allowed, but normalization that rewrites an existing +declaration value must be strict: it must serialize from parsed token/component +data with equivalent CSS semantics. If equivalence cannot be proven, the +mutation returns `false`. + +`set_value()` trims supplied values according to CSS whitespace. + +When possible, `set_value()` replaces the current declaration's value and +priority portion rather than normalizing the whole declaration. Correctness wins +over whitespace preservation. + +## Invalid Fragments + +Declaration discovery follows CSS semantics. If CSS would ignore text as a +declaration in a style attribute declaration-list context, the processor does +not expose it as a declaration. + +Ignored invalid fragments are preserved where possible. Mutations touching a +style containing invalid chunks must verify that the declaration-list structure +remains safe. If preservation and a requested mutation are incompatible, the +mutation returns `false` or normalizes only the range necessary to preserve +semantics and structure. + +## Append And EOF Repair + +`append_declaration()` may succeed even when trailing text is malformed, but only +when the processor can preserve existing CSS semantics. + +When trailing declaration text is valid only because CSS closes an unclosed +function or block at EOF, appending must materialize the missing closing +delimiters before inserting the new declaration. + +Example: + +```css +color: var(--x +``` + +Appending `background: white` should produce a structurally safe equivalent such +as: + +```css +color: var(--x); background: white; +``` + +EOF repair is part of a successful append and appears in `get_updated_style()`. +Repair should insert only the specific missing delimiters discovered from parser +or tokenizer state, plus the boundary needed before the appended declaration. If +the processor cannot determine a precise repair, append returns `false`. + +EOF repair is essential for append. `set_value()` replacement values must be +self-contained and do not receive EOF repair. + +## Test Requirements + +Tests should cover: + +- private construction and `create()` behavior; +- decoded string input boundary; +- declaration traversal, duplicate preservation, and property-name matching; +- getter `null` behavior off-cursor; +- absence of public raw value getter in the first API; +- `set_value()` priority preservation, setting, clearing, and empty-value + removal; +- `set_important()` setting, clearing, cursor behavior, and safe normalization; +- rejection of unsafe values and property names with atomic failure; +- append behavior, duplicate appends, exhausted cursor behavior, and empty-value + rejection; +- EOF repair for append and failure when repair cannot be precise; +- invalid fragments, comments, adjacent declarations, and removal ranges; +- preservation of authored spelling where required; +- integration with `WP_HTML_Tag_Processor::set_attribute()` after callers supply + decoded string input. From b7aae8f46bc4003ac6e41830aa65a2908f280f0a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 01:33:44 +0200 Subject: [PATCH 08/10] Clarify style processor factory contract --- docs/wp-html-style-attribute-processor-spec.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index c1d23cfe7c223..3728c39f520e0 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -23,11 +23,14 @@ The processor's north star is: Creation uses: ```php -WP_HTML_Style_Attribute_Processor::create( string $decoded_css_text ): static +WP_HTML_Style_Attribute_Processor::create( $decoded_css_text ) ``` -The constructor is private. `create()` always returns a processor instance for a -string input. +The constructor is private. `create()` accepts only string input and returns a +processor instance of the called class. The implementation must remain +compatible with WordPress' supported PHP versions, so the runtime signature may +use PHPDoc and explicit validation instead of PHP syntax unavailable in those +versions. Callers must not pass raw `WP_HTML_Tag_Processor::get_attribute( 'style' )` results directly unless they have already checked that the result is a string. @@ -60,7 +63,7 @@ The internal declaration data consists of: Initial public methods: ```php -public static function create( string $decoded_css_text ): static; +public static function create( $decoded_css_text ); public function next_declaration( ?string $property_name = null ): bool; public function get_property_name(): ?string; public function is_important(): ?bool; From ef831a0aed93235fa4c1d22ea63054edb741da16 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 01:33:52 +0200 Subject: [PATCH 09/10] Implement style attribute processor contract --- ...lass-wp-html-style-attribute-processor.php | 520 +++++++++++++++--- .../wpHtmlStyleAttributeProcessor.php | 514 ++++++++++++----- 2 files changed, 822 insertions(+), 212 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index 868934202c8fb..fffd59060c9db 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -15,10 +15,12 @@ * delimiting braces. It does not parse HTML attribute syntax, decode character * references, or operate on raw HTML markup. * - * Values read from {@see WP_HTML_Tag_Processor::get_attribute()} can be passed - * directly to this class, and values returned by + * String values read from {@see WP_HTML_Tag_Processor::get_attribute()} can be + * passed to this class, and values returned by * {@see WP_HTML_Style_Attribute_Processor::get_updated_style()} can be passed - * back to {@see WP_HTML_Tag_Processor::set_attribute()}. + * back to {@see WP_HTML_Tag_Processor::set_attribute()}. Missing and boolean + * style attributes are an HTML concern and must be handled before creating this + * processor. * * Unlike associative-array based style helpers, this processor preserves the * declaration list model of CSS. Multiple declarations with the same property @@ -32,7 +34,7 @@ class WP_HTML_Style_Attribute_Processor { * * @var string */ - const WHITESPACE = " \t\n\r\f"; + private const WHITESPACE = " \t\n\r\f"; /** * Decoded style attribute value. @@ -51,7 +53,7 @@ class WP_HTML_Style_Attribute_Processor { /** * Parsed declarations. * - * @var array + * @var array */ private $declarations = array(); @@ -93,16 +95,27 @@ class WP_HTML_Style_Attribute_Processor { /** * Constructor. * - * @param string|bool|null $style Decoded CSS text value from a style attribute. + * Do not instantiate directly. Use + * {@see WP_HTML_Style_Attribute_Processor::create()} instead. + * + * @param string $style Decoded CSS text value from a style attribute. + */ + private function __construct( string $style ) { + $this->style = $style; + } + + /** + * Creates a processor for decoded CSS text from a style attribute. + * + * @param string $decoded_css_text Decoded CSS text value from a style attribute. + * @return static Created processor. */ - public function __construct( $style = '' ) { - if ( null === $style || true === $style ) { - $style = ''; - } elseif ( ! is_string( $style ) ) { - $style = ''; + public static function create( $decoded_css_text ) { + if ( ! is_string( $decoded_css_text ) ) { + throw new TypeError( __METHOD__ . '(): Argument #1 ($decoded_css_text) must be of type string' ); } - $this->style = $style; + return new static( $decoded_css_text ); } /** @@ -143,33 +156,15 @@ public function get_property_name(): ?string { return null === $declaration ? null : $declaration['name']; } - /** - * Gets the current declaration's CSS value. - * - * The returned value does not include a trailing !important priority. - * - * @return string|null CSS value, or null when not on a declaration. - */ - public function get_value(): ?string { - $declaration = $this->get_current_declaration(); - if ( null === $declaration ) { - return null; - } - - return trim( - substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), - self::WHITESPACE - ); - } - /** * Indicates whether the current declaration has an !important priority. * - * @return bool Whether the current declaration has an !important priority. + * @return bool|null Whether the current declaration has an !important priority, + * or null when not on a declaration. */ - public function is_important(): bool { + public function is_important(): ?bool { $declaration = $this->get_current_declaration(); - return null !== $declaration && $declaration['important']; + return null === $declaration ? null : $declaration['important']; } /** @@ -182,12 +177,37 @@ public function is_important(): bool { */ public function set_value( string $value, ?bool $important = null ): bool { $declaration = $this->get_current_declaration(); - if ( null === $declaration || ! $this->is_valid_declaration_value( $value ) ) { + if ( null === $declaration ) { + return false; + } + + if ( '' === $value ) { + return $this->remove_declaration(); + } + + if ( ! $this->is_valid_declaration_value( $value ) ) { return false; } $is_important = null === $important ? $declaration['important'] : $important; $text = $this->serialize_declaration( $declaration['raw_name'], $value, $is_important ); + $parsed_value = $this->get_parsed_declaration_value( $text ); + if ( null === $parsed_value ) { + return false; + } + + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['start'], + $declaration['after'] - $declaration['start'], + $text, + $is_important, + $parsed_value + ) + ) { + return false; + } $this->queue_lexical_update( $declaration['start'], @@ -201,6 +221,98 @@ public function set_value( string $value, ?bool $important = null ): bool { return true; } + /** + * Sets the !important priority of the current declaration. + * + * @param bool $important Whether the declaration should be important. + * @return bool Whether the current declaration priority is set as requested. + */ + public function set_important( bool $important ): bool { + $declaration = $this->get_current_declaration(); + if ( null === $declaration ) { + return false; + } + + if ( $important === $declaration['important'] ) { + return true; + } + + if ( $important ) { + if ( $declaration['value_start'] >= $declaration['value_end'] ) { + return false; + } + + $insert_at = $declaration['value_end']; + $insert_text = ' !important'; + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['value_end'], + 0, + $insert_text, + true + ) + ) { + $eof_repair = strlen( $this->style ) === $declaration['after'] + ? $this->get_eof_repair( $declaration['after'] ) + : null; + $current_value = substr( $this->style, $declaration['value_start'], $declaration['after'] - $declaration['value_start'] ); + + if ( + null === $eof_repair || + '' === $eof_repair || + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['after'], + 0, + $eof_repair . $insert_text, + true, + trim( $current_value . $eof_repair, self::WHITESPACE ) + ) + ) { + return false; + } + + $insert_at = $declaration['after']; + $insert_text = $eof_repair . $insert_text; + } + + $this->queue_lexical_update( + $insert_at, + 0, + $insert_text, + $this->current_declaration + ); + } else { + if ( null === $declaration['important_start'] || null === $declaration['important_end'] ) { + return false; + } + + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['important_start'], + $declaration['important_end'] - $declaration['important_start'], + '', + false + ) + ) { + return false; + } + + $this->queue_lexical_update( + $declaration['important_start'], + $declaration['important_end'] - $declaration['important_start'], + '', + $this->current_declaration + ); + } + + $this->apply_lexical_updates( $this->current_declaration ); + + return true; + } + /** * Removes the current declaration from the style attribute value. * @@ -219,6 +331,10 @@ public function remove_declaration(): bool { $remove_end = $this->get_offset_after_following_whitespace( $declaration['after'] ); $replacement = ( $remove_start > 0 && $remove_end < strlen( $this->style ) ) ? ' ' : ''; + if ( ! $this->is_remove_declaration_safe( $this->current_declaration, $remove_start, $remove_end - $remove_start, $replacement ) ) { + return false; + } + $this->queue_lexical_update( $remove_start, $remove_end - $remove_start, @@ -243,7 +359,11 @@ public function remove_declaration(): bool { * @return bool Whether the declaration was appended. */ public function append_declaration( string $property_name, string $value, bool $important = false ): bool { - if ( ! $this->is_valid_property_name( $property_name ) || ! $this->is_valid_declaration_value( $value ) ) { + if ( + '' === trim( $value, self::WHITESPACE ) || + ! $this->is_valid_property_name( $property_name ) || + ! $this->is_valid_declaration_value( $value ) + ) { return false; } @@ -252,17 +372,28 @@ public function append_declaration( string $property_name, string $value, bool $ $old_declaration_count = count( $this->declarations ); $trimmed_style = rtrim( $this->style, self::WHITESPACE ); $insert_at = strlen( $trimmed_style ); - $separator = $this->get_append_separator( $insert_at ); - $declaration_text = $this->serialize_declaration( $property_name, $value, $important ); + $repair_at = strlen( $this->style ); + $eof_repair = $this->get_eof_repair( $repair_at ); + if ( null === $eof_repair ) { + return false; + } + + $separator = $this->get_append_separator( $insert_at ); + $serialized_name = $this->is_custom_property_name( $property_name ) ? $property_name : strtolower( $property_name ); + $declaration_text = $this->serialize_declaration( $serialized_name, $value, $important ); + $parsed_value = $this->get_parsed_declaration_value( $declaration_text ); + if ( null === $parsed_value ) { + return false; + } - if ( ! $this->is_parseable_append( $insert_at, $separator, $declaration_text, $old_declaration_count ) ) { + if ( ! $this->is_parseable_append( $repair_at, $eof_repair, $separator, $declaration_text, $old_declaration_count, $serialized_name, $parsed_value, $important ) ) { return false; } $this->queue_lexical_update( - $insert_at, + $repair_at, 0, - $separator . $declaration_text, + $eof_repair . $separator . $declaration_text, null ); @@ -535,7 +666,7 @@ private function consume_declaration_segment( int $index ): array { * @param int $start_index First token index in the segment. * @param int $end_index Token index after the segment. * @param int $after Byte offset after the declaration. - * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool}|null + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool, important_start:int|null, important_end:int|null}|null */ private function parse_declaration_segment( int $leading_start, int $start_index, int $end_index, int $after ): ?array { $index = $this->skip_ignored_tokens( $start_index, $end_index ); @@ -549,6 +680,8 @@ private function parse_declaration_segment( int $leading_start, int $start_index return null; } + $name = $this->is_custom_property_name( $name ) ? $name : strtolower( $name ); + ++$index; $index = $this->skip_ignored_tokens( $index, $end_index ); @@ -560,6 +693,8 @@ private function parse_declaration_segment( int $leading_start, int $start_index $value_start_index = $this->skip_ignored_tokens( $index + 1, $end_index ); $value_end_index = $this->trim_ignored_tokens( $value_start_index, $end_index ); $important = false; + $important_start = null; + $important_end = null; $top_level_tokens = $this->get_top_level_non_ignored_token_indexes( $value_start_index, $value_end_index ); $top_level_count = count( $top_level_tokens ); $last_value_index = $top_level_count > 0 ? $top_level_tokens[ $top_level_count - 1 ] : null; @@ -574,6 +709,8 @@ private function parse_declaration_segment( int $leading_start, int $start_index '!' === $this->tokens[ $before_last_index ]['value'] ) { $important = true; + $important_start = $this->tokens[ $before_last_index ]['start']; + $important_end = $this->tokens[ $last_value_index ]['end']; $value_end_index = $this->trim_ignored_tokens( $value_start_index, $before_last_index ); } @@ -586,15 +723,17 @@ private function parse_declaration_segment( int $leading_start, int $start_index } return array( - 'name' => $name, - 'raw_name' => substr( $this->style, $name_token['start'], $name_token['length'] ), - 'leading_start' => $leading_start, - 'start' => $name_token['start'], - 'after' => $after, - 'trailing_end' => $after, - 'value_start' => $value_start, - 'value_end' => $value_end, - 'important' => $important, + 'name' => $name, + 'raw_name' => substr( $this->style, $name_token['start'], $name_token['length'] ), + 'leading_start' => $leading_start, + 'start' => $name_token['start'], + 'after' => $after, + 'trailing_end' => $after, + 'value_start' => $value_start, + 'value_end' => $value_end, + 'important' => $important, + 'important_start' => $important_start, + 'important_end' => $important_end, ); } @@ -741,7 +880,7 @@ private function get_offset_after_following_whitespace( int $offset ): int { /** * Gets the current declaration metadata. * - * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool}|null + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool, important_start:int|null, important_end:int|null}|null */ private function get_current_declaration(): ?array { if ( @@ -755,6 +894,122 @@ private function get_current_declaration(): ?array { return $this->declarations[ $this->current_declaration ]; } + /** + * Checks whether a replacement preserves the current declaration's structure. + * + * @param array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, important:bool, important_start:int|null, important_end:int|null} $declaration Current declaration metadata. + * @param int $start Byte offset at which to start the replacement. + * @param int $length Number of bytes to replace. + * @param string $text Replacement text. + * @param bool $expected_important Expected important flag after replacement. + * @param string|null $expected_value Optional expected value source after replacement. + * @return bool Whether the replacement is safe. + */ + private function is_current_declaration_replacement_safe( array $declaration, int $start, int $length, string $text, bool $expected_important, ?string $expected_value = null ): bool { + $candidate_style = substr( $this->style, 0, $start ) . $text . substr( $this->style, $start + $length ); + $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); + + if ( + count( $candidate->declarations ) !== count( $this->declarations ) || + ! isset( $candidate->declarations[ $this->current_declaration ] ) + ) { + return false; + } + + $updated_declaration = $candidate->declarations[ $this->current_declaration ]; + if ( + $updated_declaration['name'] !== $declaration['name'] || + $updated_declaration['important'] !== $expected_important + ) { + return false; + } + + $old_value = trim( + substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + $new_value = trim( + substr( $candidate_style, $updated_declaration['value_start'], $updated_declaration['value_end'] - $updated_declaration['value_start'] ), + self::WHITESPACE + ); + + return null === $expected_value ? $old_value === $new_value : $expected_value === $new_value; + } + + /** + * Checks whether removing a declaration preserves remaining structure. + * + * @param int $removed_declaration Removed declaration index. + * @param int $start Byte offset at which to start the replacement. + * @param int $length Number of bytes to replace. + * @param string $text Replacement text. + * @return bool Whether the removal is safe. + */ + private function is_remove_declaration_safe( int $removed_declaration, int $start, int $length, string $text ): bool { + $candidate_style = substr( $this->style, 0, $start ) . $text . substr( $this->style, $start + $length ); + $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); + + if ( count( $candidate->declarations ) !== count( $this->declarations ) - 1 ) { + return false; + } + + $candidate_index = 0; + foreach ( $this->declarations as $index => $declaration ) { + if ( $removed_declaration === $index ) { + continue; + } + + if ( ! isset( $candidate->declarations[ $candidate_index ] ) ) { + return false; + } + + $candidate_declaration = $candidate->declarations[ $candidate_index ]; + $value = trim( + substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + $candidate_value = trim( + substr( $candidate_style, $candidate_declaration['value_start'], $candidate_declaration['value_end'] - $candidate_declaration['value_start'] ), + self::WHITESPACE + ); + + if ( + $candidate_declaration['name'] !== $declaration['name'] || + $candidate_declaration['important'] !== $declaration['important'] || + $candidate_value !== $value + ) { + return false; + } + + ++$candidate_index; + } + + return true; + } + + /** + * Gets the parsed value source from a serialized declaration. + * + * @param string $declaration_text Serialized declaration text. + * @return string|null Parsed declaration value, or null if parsing failed. + */ + private function get_parsed_declaration_value( string $declaration_text ): ?string { + $processor = new self( $declaration_text ); + $processor->ensure_parsed(); + + if ( 1 !== count( $processor->declarations ) ) { + return null; + } + + $declaration = $processor->declarations[0]; + return trim( + substr( $declaration_text, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + } + /** * Applies queued lexical updates and reparses the updated style attribute value. * @@ -893,6 +1148,107 @@ private function get_last_top_level_non_ignored_token_before( int $insert_at ): return $last_token; } + /** + * Gets required closing delimiters before appending at EOF. + * + * CSS closes unterminated functions and simple blocks at EOF. Appending must + * materialize those closers before adding a new declaration. + * + * @param int $insert_at Byte offset at which the declaration will be inserted. + * @return string|null Closing delimiters, or null when repair cannot be precise. + */ + private function get_eof_repair( int $insert_at ): ?string { + $stack = array(); + + foreach ( $this->tokens as $token ) { + if ( $token['start'] >= $insert_at ) { + break; + } + + if ( $token['end'] > $insert_at ) { + return null; + } + + switch ( $token['type'] ) { + case WP_CSS_Token_Processor::TOKEN_BAD_STRING: + case WP_CSS_Token_Processor::TOKEN_BAD_URL: + return null; + + case WP_CSS_Token_Processor::TOKEN_COMMENT: + if ( + strlen( $this->style ) === $token['end'] && + '*/' !== substr( $this->style, -2 ) + ) { + return null; + } + break; + + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: + $stack[] = ')'; + break; + + case WP_CSS_Token_Processor::TOKEN_URL: + if ( ! $this->is_url_token_closed( $token ) ) { + $stack[] = ')'; + } + break; + + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: + $stack[] = ']'; + break; + + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + $stack[] = '}'; + break; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: + if ( ! empty( $stack ) && ')' === end( $stack ) ) { + array_pop( $stack ); + } + break; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: + if ( ! empty( $stack ) && ']' === end( $stack ) ) { + array_pop( $stack ); + } + break; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: + if ( ! empty( $stack ) && '}' === end( $stack ) ) { + array_pop( $stack ); + } + break; + } + } + + return implode( '', array_reverse( $stack ) ); + } + + /** + * Checks whether a URL token source contains its real closing parenthesis. + * + * @param array{type:string, value:string|null, start:int, length:int, end:int} $token URL token metadata. + * @return bool Whether the URL token was closed in source. + */ + private function is_url_token_closed( array $token ): bool { + $sentinel = ' !wp-style-attribute-processor-sentinel'; + $source = substr( $this->style, $token['start'], $token['length'] ); + $scanner = WP_CSS_Token_Processor::create( $source . $sentinel ); + if ( null === $scanner || ! $scanner->next_token() ) { + return false; + } + + if ( + WP_CSS_Token_Processor::TOKEN_URL !== $scanner->get_token_type() || + $scanner->get_token_length() > strlen( $source ) + ) { + return false; + } + + return $scanner->next_token(); + } + /** * Checks whether an appended declaration will parse as a new declaration. * @@ -901,15 +1257,19 @@ private function get_last_top_level_non_ignored_token_before( int $insert_at ): * appending a top-level declaration are incompatible. * * @param int $insert_at Byte offset at which the declaration will be inserted. + * @param string $eof_repair EOF repair text. * @param string $separator Separator text. * @param string $declaration_text Serialized declaration text. * @param int $old_declaration_count Number of declarations before appending. + * @param string $expected_name Expected parsed property name. + * @param string $expected_value Expected parsed value source. + * @param bool $expected_important Expected important flag. * @return bool Whether the appended declaration is parseable. */ - private function is_parseable_append( int $insert_at, string $separator, string $declaration_text, int $old_declaration_count ): bool { - $expected_start = $insert_at + strlen( $separator ); + private function is_parseable_append( int $insert_at, string $eof_repair, string $separator, string $declaration_text, int $old_declaration_count, string $expected_name, string $expected_value, bool $expected_important ): bool { + $expected_start = $insert_at + strlen( $eof_repair ) + strlen( $separator ); $expected_after = $expected_start + strlen( $declaration_text ); - $candidate_style = substr( $this->style, 0, $insert_at ) . $separator . $declaration_text . substr( $this->style, $insert_at ); + $candidate_style = substr( $this->style, 0, $insert_at ) . $eof_repair . $separator . $declaration_text . substr( $this->style, $insert_at ); $candidate = new self( $candidate_style ); $candidate->ensure_parsed(); @@ -919,7 +1279,16 @@ private function is_parseable_append( int $insert_at, string $separator, string foreach ( $candidate->declarations as $declaration ) { if ( $expected_start === $declaration['start'] && $expected_after === $declaration['after'] ) { - return true; + $parsed_value = trim( + substr( $candidate_style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + + return ( + $expected_name === $declaration['name'] && + $expected_value === $parsed_value && + $expected_important === $declaration['important'] + ); } } @@ -1025,6 +1394,14 @@ private function is_custom_property_name( string $property_name ): bool { * @return bool Whether the property name is valid. */ private function is_valid_property_name( string $property_name ): bool { + if ( + '' === $property_name || + false !== strpos( $property_name, '\\' ) || + '--' === $property_name + ) { + return false; + } + $processor = WP_CSS_Token_Processor::create( $property_name ); if ( null === $processor || ! $processor->next_token() ) { return false; @@ -1068,6 +1445,10 @@ private function is_valid_property_name( string $property_name ): bool { * @return bool Whether the value is valid. */ private function is_valid_declaration_value( string $value ): bool { + if ( '' === trim( $value, self::WHITESPACE ) ) { + return false; + } + $sentinel_property = '--wp-style-attribute-processor-sentinel'; $processor = WP_CSS_Token_Processor::create( $value . ';' . $sentinel_property . ':1' ); if ( null === $processor ) { @@ -1135,19 +1516,24 @@ private function is_valid_declaration_value( string $value ): bool { return false; } - $top_level_count = count( $top_level_tokens ); - if ( $top_level_count < 2 ) { - return true; + if ( empty( $top_level_tokens ) ) { + return false; } - $last_value_token = $top_level_tokens[ $top_level_count - 1 ]; - $before_last_token = $top_level_tokens[ $top_level_count - 2 ]; + for ( $i = 1; $i < count( $top_level_tokens ); $i++ ) { + $token = $top_level_tokens[ $i ]; + $before_token = $top_level_tokens[ $i - 1 ]; - return ! ( - WP_CSS_Token_Processor::TOKEN_IDENT === $last_value_token['type'] && - 0 === strcasecmp( 'important', (string) $last_value_token['value'] ) && - WP_CSS_Token_Processor::TOKEN_DELIM === $before_last_token['type'] && - '!' === $before_last_token['value'] - ); + if ( + WP_CSS_Token_Processor::TOKEN_IDENT === $token['type'] && + 0 === strcasecmp( 'important', (string) $token['value'] ) && + WP_CSS_Token_Processor::TOKEN_DELIM === $before_token['type'] && + '!' === $before_token['value'] + ) { + return false; + } + } + + return true; } } diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 3e7a235578631..7d997f60fc31a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -13,73 +13,96 @@ */ class Tests_HtmlApi_WpHtmlStyleAttributeProcessor extends WP_UnitTestCase { /** - * @covers ::__construct - * @covers ::next_declaration + * @covers ::create * @covers ::get_updated_style + */ + public function test_create_accepts_decoded_css_text_strings() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red' ); + + $this->assertSame( 'color: red', $processor->get_updated_style() ); + } + + /** + * @covers ::create * - * @dataProvider data_html_style_attribute_values + * @dataProvider data_non_string_style_attribute_values * - * @param string $html HTML containing a first div. - * @param string $expected_style Expected normalized style input. - * @param string|null $property_name Expected first property name. + * @param mixed $style_attribute_value Non-string style attribute value. */ - public function test_constructor_accepts_html_tag_processor_style_attribute_values( string $html, string $expected_style, ?string $property_name ) { - $tags = new WP_HTML_Tag_Processor( $html ); - $this->assertTrue( $tags->next_tag( 'div' ) ); + public function test_create_rejects_non_string_style_attribute_values( $style_attribute_value ) { + $this->expectException( TypeError::class ); + + WP_HTML_Style_Attribute_Processor::create( $style_attribute_value ); + } - $processor = new WP_HTML_Style_Attribute_Processor( $tags->get_attribute( 'style' ) ); + /** + * @coversNothing + */ + public function test_constructor_is_not_public() { + $reflection = new ReflectionClass( WP_HTML_Style_Attribute_Processor::class ); + $constructor = $reflection->getConstructor(); - $this->assertSame( $expected_style, $processor->get_updated_style() ); + $this->assertNotNull( $constructor ); + $this->assertFalse( $constructor->isPublic() ); + } - if ( null === $property_name ) { - $this->assertFalse( $processor->next_declaration() ); - } else { - $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( $property_name, $processor->get_property_name() ); - } + /** + * @coversNothing + */ + public function test_raw_value_getter_is_not_public_api() { + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_value' ) ); + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_raw_value' ) ); } /** * @covers ::next_declaration * @covers ::get_property_name - * @covers ::get_value + * @covers ::is_important */ public function test_next_declaration_reads_properties_in_order_and_preserves_duplicates() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: color(display-p3 1 0 0); background: blue' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'COLOR: red; color: blue !important; background: white' ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'color', $processor->get_property_name() ); - $this->assertSame( 'red', $processor->get_value() ); + $this->assertFalse( $processor->is_important() ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'color', $processor->get_property_name() ); - $this->assertSame( 'color(display-p3 1 0 0)', $processor->get_value() ); + $this->assertTrue( $processor->is_important() ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'blue', $processor->get_value() ); $this->assertFalse( $processor->next_declaration() ); + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); } /** * @covers ::next_declaration + * @covers ::get_property_name */ public function test_next_declaration_query_visits_matching_duplicate_properties() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white; COLOR: blue;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white; COLOR: blue;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); - $this->assertSame( 'red', $processor->get_value() ); + $this->assertSame( 'color', $processor->get_property_name() ); $this->assertTrue( $processor->next_declaration( 'color' ) ); - $this->assertSame( 'blue', $processor->get_value() ); + $this->assertSame( 'color', $processor->get_property_name() ); $this->assertFalse( $processor->next_declaration( 'color' ) ); + } - $processor = new WP_HTML_Style_Attribute_Processor( '--Tone: warm; --tone: cool;' ); + /** + * @covers ::next_declaration + * @covers ::get_property_name + */ + public function test_custom_property_queries_match_exact_decoded_casing() { + $processor = WP_HTML_Style_Attribute_Processor::create( '--Tone: warm; --tone: cool;' ); $this->assertTrue( $processor->next_declaration( '--tone' ) ); - $this->assertSame( 'cool', $processor->get_value() ); + $this->assertSame( '--tone', $processor->get_property_name() ); $this->assertFalse( $processor->next_declaration( '--tone' ) ); } @@ -88,31 +111,25 @@ public function test_next_declaration_query_visits_matching_duplicate_properties * @covers ::next_declaration */ public function test_next_declaration_accepts_named_property_name_argument() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); - $this->assertTrue( $processor->next_declaration( property_name: 'background' ) ); + $this->assertTrue( $processor->next_declaration( 'background' ) ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'white', $processor->get_value() ); } /** * @covers ::is_important - * @covers ::get_value + * + * @dataProvider data_important_priority_syntax + * + * @param string $style Style attribute value. + * @param bool $important Expected importance. */ - public function test_important_priority_is_inspected_separately_from_value() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red !important; --tone: blue !IMPORTANT; background: white;' ); - - $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( 'red', $processor->get_value() ); - $this->assertTrue( $processor->is_important() ); - - $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( 'blue', $processor->get_value() ); - $this->assertTrue( $processor->is_important() ); + public function test_important_priority_syntax_variants( string $style, bool $important ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( 'white', $processor->get_value() ); - $this->assertFalse( $processor->is_important() ); + $this->assertSame( $important, $processor->is_important() ); } /** @@ -120,7 +137,7 @@ public function test_important_priority_is_inspected_separately_from_value() { * @covers ::get_updated_style */ public function test_set_value_updates_current_declaration_without_collapsing_duplicates() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red !important; color: color(display-p3 1 0 0);' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red !important; color: color(display-p3 1 0 0);' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->set_value( 'green', false ) ); @@ -130,54 +147,174 @@ public function test_set_value_updates_current_declaration_without_collapsing_du /** * @covers ::set_value - * @covers ::get_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_declaration_values_allow_leading_and_trailing_comments_as_trivia() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( '/* before */ green /* after */' ) ); + $this->assertTrue( $processor->append_declaration( 'background', '/* before */ white /* after */' ) ); + + $this->assertSame( 'color: /* before */ green /* after */; background: /* before */ white /* after */;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_value * @covers ::is_important */ - public function test_getters_reflect_current_declaration_after_set_value() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + public function test_set_value_preserves_sets_and_clears_importance() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red !important; background: white; border-color: black;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); - $this->assertTrue( $processor->set_value( 'green', true ) ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertTrue( $processor->is_important() ); - $this->assertSame( 'color', $processor->get_property_name() ); - $this->assertSame( 'green', $processor->get_value() ); + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_value( 'blue', true ) ); $this->assertTrue( $processor->is_important() ); + $this->assertTrue( $processor->next_declaration( 'border-color' ) ); + $this->assertTrue( $processor->set_value( 'currentColor', false ) ); + $this->assertFalse( $processor->is_important() ); + + $this->assertSame( + 'color: green !important; background: blue !important; border-color: currentColor;', + $processor->get_updated_style() + ); + } + + /** + * @covers ::set_value + * @covers ::remove_declaration + * @covers ::get_property_name + * @covers ::is_important + */ + public function test_set_value_with_empty_string_removes_current_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( '' ) ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); + $this->assertFalse( $processor->set_value( 'green' ) ); + $this->assertFalse( $processor->remove_declaration() ); + $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'background: white;', $processor->get_updated_style() ); } /** - * @covers ::remove_declaration + * @covers ::set_value * @covers ::get_updated_style */ - public function test_remove_declaration_removes_only_current_duplicate() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' ); + public function test_set_value_rejects_css_whitespace_only_values_without_removing_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( " \t\n\r\f" ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::set_important + * @covers ::is_important + * @covers ::get_updated_style + */ + public function test_set_important_sets_and_clears_priority() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white ! /*x*/ important;' ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); - $this->assertTrue( $processor->remove_declaration() ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); - $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( false ) ); + $this->assertFalse( $processor->is_important() ); + + $this->assertSame( 'color: red !important; background: white ;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_important + * @covers ::get_updated_style + */ + public function test_set_important_can_repair_unclosed_eof_component_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x /*c*/' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x /*c*/) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x /*c*/ ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x /*c*/ ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo ' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo\\)' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo\\)) !important', $processor->get_updated_style() ); + } + + /** + * @covers ::set_important + */ + public function test_set_important_returns_false_without_current_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertFalse( $processor->set_important( true ) ); } /** * @covers ::remove_declaration - * @covers ::get_property_name - * @covers ::get_value + * @covers ::get_updated_style */ - public function test_getters_return_null_after_removing_current_declaration_until_cursor_advances() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' ); + public function test_remove_declaration_removes_only_current_duplicate() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; color: blue; background: white;' ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->remove_declaration() ); - $this->assertNull( $processor->get_property_name() ); - $this->assertNull( $processor->get_value() ); - $this->assertFalse( $processor->is_important() ); - - $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } /** @@ -185,7 +322,7 @@ public function test_getters_return_null_after_removing_current_declaration_unti * @covers ::get_updated_style */ public function test_remove_declaration_removes_adjacent_duplicate_declarations() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; color: blue; background: white;' ); while ( $processor->next_declaration( 'color' ) ) { $this->assertTrue( $processor->remove_declaration() ); @@ -194,36 +331,70 @@ public function test_remove_declaration_removes_adjacent_duplicate_declarations( $this->assertSame( 'background: white;', $processor->get_updated_style() ); } + /** + * @covers ::remove_declaration + * @covers ::get_property_name + * @covers ::is_important + */ + public function test_getters_return_null_after_removing_current_declaration_until_cursor_advances() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); + $this->assertFalse( $processor->remove_declaration() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + /** * @covers ::remove_declaration * @covers ::get_updated_style */ public function test_remove_declaration_preserves_surrounding_comments() { - $processor = new WP_HTML_Style_Attribute_Processor( '/*keep*/ color: red; background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( '/*keep*/ color: red; background: white;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->remove_declaration() ); $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; /*keep*/ background: white;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->remove_declaration() ); $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; /*keep*/ background: white;' ); $this->assertTrue( $processor->next_declaration( 'background' ) ); $this->assertTrue( $processor->remove_declaration() ); $this->assertSame( 'color: red; /*keep*/', $processor->get_updated_style() ); } + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_with_invalid_fragments_preserves_remaining_structure() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; invalid; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( 'invalid; background: white;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + /** * @covers ::append_declaration * @covers ::get_updated_style */ public function test_append_declaration_adds_duplicate_declaration() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); $this->assertTrue( $processor->append_declaration( 'color', 'color(display-p3 1 0 0)', true ) ); @@ -235,7 +406,7 @@ public function test_append_declaration_adds_duplicate_declaration() { * @covers ::get_updated_style */ public function test_append_declaration_preserves_multiple_appends_in_order() { - $processor = new WP_HTML_Style_Attribute_Processor( '' ); + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); @@ -248,17 +419,17 @@ public function test_append_declaration_preserves_multiple_appends_in_order() { * @covers ::get_updated_style */ public function test_append_declaration_treats_comments_as_trivia_for_separators() { - $processor = new WP_HTML_Style_Attribute_Processor( '/*keep*/' ); + $processor = WP_HTML_Style_Attribute_Processor::create( '/*keep*/' ); $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); $this->assertSame( '/*keep*/ color: red;', $processor->get_updated_style() ); - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;/*keep*/' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;/*keep*/' ); $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); $this->assertSame( 'color: red;/*keep*/ background: white;', $processor->get_updated_style() ); - $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x;/*keep*/);' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x;/*keep*/);' ); $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); $this->assertSame( 'color: var(--x;/*keep*/); background: white;', $processor->get_updated_style() ); @@ -268,47 +439,48 @@ public function test_append_declaration_treats_comments_as_trivia_for_separators * @covers ::append_declaration * @covers ::get_updated_style */ - public function test_append_declaration_rejects_unclosed_component_value_append_points() { - $style = 'color: var(--x;/*keep*/'; - $processor = new WP_HTML_Style_Attribute_Processor( $style ); + public function test_append_declaration_lowercases_ordinary_properties_and_preserves_custom_properties() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); - $this->assertFalse( $processor->append_declaration( 'background', 'white' ) ); - $this->assertSame( $style, $processor->get_updated_style() ); + $this->assertTrue( $processor->append_declaration( 'BackgroundColor', 'red' ) ); + $this->assertTrue( $processor->append_declaration( '--Tone', 'warm' ) ); + + $this->assertSame( 'backgroundcolor: red; --Tone: warm;', $processor->get_updated_style() ); } /** * @covers ::append_declaration * @covers ::next_declaration + * @covers ::get_property_name */ public function test_appended_declarations_can_be_inspected_by_the_cursor() { - $processor = new WP_HTML_Style_Attribute_Processor( '' ); + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'color', $processor->get_property_name() ); - $this->assertSame( 'red', $processor->get_value() ); } /** * @covers ::append_declaration * @covers ::next_declaration * @covers ::get_property_name + * @covers ::set_value */ public function test_append_declaration_preserves_exhausted_cursor_until_it_advances() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); $this->assertTrue( $processor->next_declaration() ); $this->assertFalse( $processor->next_declaration() ); $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); $this->assertNull( $processor->get_property_name() ); - $this->assertNull( $processor->get_value() ); + $this->assertNull( $processor->is_important() ); $this->assertFalse( $processor->set_value( 'green' ) ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'white', $processor->get_value() ); $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } @@ -318,7 +490,7 @@ public function test_append_declaration_preserves_exhausted_cursor_until_it_adva * @covers ::get_updated_style */ public function test_append_declaration_after_removing_only_declaration() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; ' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; ' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertTrue( $processor->remove_declaration() ); @@ -327,17 +499,59 @@ public function test_append_declaration_after_removing_only_declaration() { $this->assertSame( 'background: white;', $processor->get_updated_style() ); } + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_repairs_unclosed_eof_component_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: var(--x); background: white;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: calc(1px + var(--gap' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( 'width: calc(1px + var(--gap)); color: red;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( 'background: url(foo); color: red;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo ' ); + + $this->assertTrue( $processor->append_declaration( 'border', '0' ) ); + $this->assertSame( 'background: url(foo ); border: 0;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo\\)' ); + + $this->assertTrue( $processor->append_declaration( 'border', '0' ) ); + $this->assertSame( 'background: url(foo\\)); border: 0;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_rejects_eof_repairs_that_cannot_be_precise() { + $style = 'color: var(--x;/*keep'; + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertFalse( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + /** * @covers ::next_declaration * @covers ::get_updated_style */ public function test_invalid_fragments_are_skipped_and_preserved() { $style = 'color red; @media (min-width: 1px) { color: green; } background: white;'; - $processor = new WP_HTML_Style_Attribute_Processor( $style ); + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'white', $processor->get_value() ); $this->assertFalse( $processor->next_declaration() ); $this->assertSame( $style, $processor->get_updated_style() ); } @@ -346,24 +560,21 @@ public function test_invalid_fragments_are_skipped_and_preserved() { * @covers ::next_declaration */ public function test_stray_right_brace_consumes_bad_declaration_until_semicolon() { - $processor = new WP_HTML_Style_Attribute_Processor( '} color: red; background: white;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( '} color: red; background: white;' ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'white', $processor->get_value() ); $this->assertFalse( $processor->next_declaration() ); } /** * @covers ::next_declaration - * @covers ::get_value */ public function test_values_can_contain_semicolons_inside_component_values() { - $processor = new WP_HTML_Style_Attribute_Processor( 'background: image-set(url("a;b.png") 1x); color: red;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: image-set(url("a;b.png") 1x); color: red;' ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'background', $processor->get_property_name() ); - $this->assertSame( 'image-set(url("a;b.png") 1x)', $processor->get_value() ); $this->assertTrue( $processor->next_declaration() ); $this->assertSame( 'color', $processor->get_property_name() ); @@ -375,7 +586,7 @@ public function test_values_can_contain_semicolons_inside_component_values() { * @covers ::get_updated_style */ public function test_escaped_property_names_match_decoded_names_and_preserve_raw_spelling() { - $processor = new WP_HTML_Style_Attribute_Processor( 'c\\6f lor: red; --t\\6f ne: cool; --Tone: warm;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'c\\6f lor: red; --t\\6f ne: cool; --Tone: warm;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); $this->assertSame( 'color', $processor->get_property_name() ); @@ -384,69 +595,69 @@ public function test_escaped_property_names_match_decoded_names_and_preserve_raw $this->assertTrue( $processor->next_declaration( '--tone' ) ); $this->assertSame( '--tone', $processor->get_property_name() ); - $this->assertSame( 'cool', $processor->get_value() ); + $this->assertTrue( $processor->set_value( 'cold' ) ); + $this->assertSame( 'c\\6f lor: green; --t\\6f ne: cold; --Tone: warm;', $processor->get_updated_style() ); $this->assertFalse( $processor->next_declaration( '--tone' ) ); } /** * @covers ::is_important - * @covers ::get_value */ public function test_important_inside_unclosed_function_is_not_declaration_priority() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x, red !important' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x, red !important' ); $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( 'var(--x, red !important', $processor->get_value() ); $this->assertFalse( $processor->is_important() ); } /** - * @covers ::is_important - * @covers ::get_value - * - * @dataProvider data_important_priority_syntax - * - * @param string $style Style attribute value. - * @param string $value Expected declaration value. - * @param bool $important Expected importance. + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style */ - public function test_important_priority_syntax_variants( string $style, string $value, bool $important ) { - $processor = new WP_HTML_Style_Attribute_Processor( $style ); + public function test_declaration_values_reject_top_level_semicolons_and_important_priority() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); - $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( $value, $processor->get_value() ); - $this->assertSame( $important, $processor->is_important() ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( 'blue; color: green' ) ); + $this->assertFalse( $processor->set_value( 'green ! important', false ) ); + $this->assertFalse( $processor->set_value( 'green ! important foo', false ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'blue; color: green' ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'white !important' ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'white !important foo' ) ); + + $this->assertSame( 'color: red;', $processor->get_updated_style() ); } /** * @covers ::append_declaration * @covers ::get_updated_style */ - public function test_declaration_values_reject_top_level_semicolons() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + public function test_append_declaration_rejects_empty_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); - $this->assertFalse( $processor->append_declaration( 'background', 'blue; color: green' ) ); + $this->assertFalse( $processor->append_declaration( 'background', '' ) ); + $this->assertFalse( $processor->append_declaration( 'background', '/* comment */' ) ); $this->assertSame( 'color: red;', $processor->get_updated_style() ); } /** * @covers ::set_value - * @covers ::append_declaration * @covers ::get_updated_style */ - public function test_declaration_values_reject_top_level_important_priority() { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + public function test_set_value_rejects_comment_only_values_without_removing_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); $this->assertTrue( $processor->next_declaration( 'color' ) ); - $this->assertFalse( $processor->set_value( 'green ! important', false ) ); - $this->assertFalse( $processor->append_declaration( 'background', 'white !important' ) ); - + $this->assertFalse( $processor->set_value( '/* comment */' ) ); $this->assertSame( 'color: red;', $processor->get_updated_style() ); + $this->assertSame( 'color', $processor->get_property_name() ); } /** * @covers ::append_declaration + * @covers ::set_value * @covers ::get_updated_style * * @dataProvider data_malformed_declaration_values @@ -454,20 +665,46 @@ public function test_declaration_values_reject_top_level_important_priority() { * @param string $value Malformed CSS declaration value. */ public function test_declaration_values_reject_malformed_eof_tokens( string $value ) { - $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' ); + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( $value ) ); $this->assertFalse( $processor->append_declaration( 'background', $value ) ); $this->assertSame( 'color: red;', $processor->get_updated_style() ); } /** + * @covers ::append_declaration * @covers ::get_updated_style */ - public function test_updated_style_can_be_set_on_html_tag_processor() { + public function test_property_names_reject_escaped_source_identifiers() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertFalse( $processor->append_declaration( 'color\\', 'red' ) ); + $this->assertFalse( $processor->append_declaration( 'c\\6f lor', 'red' ) ); + $this->assertSame( '', $processor->get_updated_style() ); + } + + /** + * @coversNothing + */ + public function test_whitespace_constant_is_not_public_api() { + $reflection = new ReflectionClass( WP_HTML_Style_Attribute_Processor::class ); + + $this->assertFalse( $reflection->hasConstant( 'WHITESPACE' ) && $reflection->getReflectionConstant( 'WHITESPACE' )->isPublic() ); + } + + /** + * @covers ::get_updated_style + */ + public function test_updated_style_can_be_set_on_html_tag_processor_after_string_check() { $tags = new WP_HTML_Tag_Processor( '
Text
' ); $this->assertTrue( $tags->next_tag( 'div' ) ); - $styles = new WP_HTML_Style_Attribute_Processor( $tags->get_attribute( 'style' ) ); + $style = $tags->get_attribute( 'style' ); + $this->assertIsString( $style ); + + $styles = WP_HTML_Style_Attribute_Processor::create( $style ); $this->assertTrue( $styles->append_declaration( 'background', 'white' ) ); $this->assertTrue( $tags->set_attribute( 'style', $styles->get_updated_style() ) ); @@ -480,29 +717,27 @@ public function test_updated_style_can_be_set_on_html_tag_processor() { /** * Data provider. * - * @return array + * @return array */ - public static function data_html_style_attribute_values(): array { + public static function data_non_string_style_attribute_values(): array { return array( - 'missing style attribute' => array( '
Text
', '', null ), - 'boolean style attribute' => array( '
Text
', '', null ), - 'empty style attribute' => array( '
Text
', '', null ), - 'valued style attribute' => array( '
Text
', 'color: red', 'color' ), + 'missing style attribute' => array( null ), + 'boolean style attribute' => array( true ), ); } /** * Data provider. * - * @return array + * @return array */ public static function data_important_priority_syntax(): array { return array( - 'no whitespace' => array( 'color: red!important;', 'red', true ), - 'whitespace after bang' => array( 'color: red ! important;', 'red', true ), - 'comment after bang' => array( 'color: red ! /*x*/ important;', 'red', true ), - 'escaped important' => array( 'color: red !\\69mportant;', 'red', true ), - 'extra trailing token' => array( 'color: red ! important foo;', 'red ! important foo', false ), + 'no whitespace' => array( 'color: red!important;', true ), + 'whitespace after bang' => array( 'color: red ! important;', true ), + 'comment after bang' => array( 'color: red ! /*x*/ important;', true ), + 'escaped important' => array( 'color: red !\\69mportant;', true ), + 'extra trailing token' => array( 'color: red ! important foo;', false ), ); } @@ -523,15 +758,4 @@ public static function data_malformed_declaration_values(): array { 'eof escape' => array( 'red\\' ), ); } - - /** - * @covers ::append_declaration - * @covers ::get_updated_style - */ - public function test_property_names_reject_eof_escapes() { - $processor = new WP_HTML_Style_Attribute_Processor( '' ); - - $this->assertFalse( $processor->append_declaration( 'color\\', 'red' ) ); - $this->assertSame( '', $processor->get_updated_style() ); - } } From 0f7d2823b1f618ee8aed8c9c74be329609ecd052 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 26 Aug 2026 14:43:24 +0400 Subject: [PATCH 10/10] Pin style processor mutation sequencing --- ...lass-wp-html-style-attribute-processor.php | 20 +++++++++++ .../wpHtmlStyleAttributeProcessor.php | 33 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php index fffd59060c9db..52b990ccee1f6 100644 --- a/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -98,6 +98,8 @@ class WP_HTML_Style_Attribute_Processor { * Do not instantiate directly. Use * {@see WP_HTML_Style_Attribute_Processor::create()} instead. * + * @since {WP_VERSION} + * * @param string $style Decoded CSS text value from a style attribute. */ private function __construct( string $style ) { @@ -107,6 +109,8 @@ private function __construct( string $style ) { /** * Creates a processor for decoded CSS text from a style attribute. * + * @since {WP_VERSION} + * * @param string $decoded_css_text Decoded CSS text value from a style attribute. * @return static Created processor. */ @@ -124,6 +128,8 @@ public static function create( $decoded_css_text ) { * When a property name is provided, normal CSS properties are matched * ASCII-case-insensitively while custom properties are matched exactly. * + * @since {WP_VERSION} + * * @param string|null $property_name Optional declaration property name to match. * @return bool Whether a matching declaration was found. */ @@ -149,6 +155,8 @@ public function next_declaration( ?string $property_name = null ): bool { /** * Gets the current declaration's property name. * + * @since {WP_VERSION} + * * @return string|null Property name, or null when not on a declaration. */ public function get_property_name(): ?string { @@ -159,6 +167,8 @@ public function get_property_name(): ?string { /** * Indicates whether the current declaration has an !important priority. * + * @since {WP_VERSION} + * * @return bool|null Whether the current declaration has an !important priority, * or null when not on a declaration. */ @@ -170,6 +180,8 @@ public function is_important(): ?bool { /** * Sets the value of the current declaration. * + * @since {WP_VERSION} + * * @param string $value CSS declaration value, without the property name. * @param bool|null $important Optional. Whether the declaration should be important. * Defaults to preserving the current priority. @@ -224,6 +236,8 @@ public function set_value( string $value, ?bool $important = null ): bool { /** * Sets the !important priority of the current declaration. * + * @since {WP_VERSION} + * * @param bool $important Whether the declaration should be important. * @return bool Whether the current declaration priority is set as requested. */ @@ -319,6 +333,8 @@ public function set_important( bool $important ): bool { * Only the current declaration is removed. Other declarations with the same * property name remain in place. * + * @since {WP_VERSION} + * * @return bool Whether the current declaration was removed. */ public function remove_declaration(): bool { @@ -353,6 +369,8 @@ public function remove_declaration(): bool { * Appending does not remove or replace existing declarations with the same * property name. * + * @since {WP_VERSION} + * * @param string $property_name CSS property name. * @param string $value CSS declaration value, without the property name. * @param bool $important Optional. Whether the declaration should be important. Default false. @@ -413,6 +431,8 @@ public function append_declaration( string $property_name, string $value, bool $ /** * Returns the updated decoded CSS text value for the style attribute. * + * @since {WP_VERSION} + * * @return string Updated decoded CSS text value for the style attribute. */ public function get_updated_style(): string { diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 7d997f60fc31a..a23f0696bc63d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -414,6 +414,39 @@ public function test_append_declaration_preserves_multiple_appends_in_order() { $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); } + /** + * @covers ::append_declaration + * @covers ::get_property_name + * @covers ::get_updated_style + * @covers ::is_important + * @covers ::next_declaration + * @covers ::remove_declaration + * @covers ::set_important + * @covers ::set_value + */ + public function test_mixed_mutations_preserve_the_logical_cursor_and_declaration_order() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green', true ) ); + $this->assertTrue( $processor->set_important( false ) ); + $this->assertTrue( $processor->append_declaration( 'border', '1px', true ) ); + + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertFalse( $processor->is_important() ); + $this->assertTrue( $processor->set_value( 'blue' ) ); + $this->assertSame( 'color: blue; background: white; border: 1px !important;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'border', $processor->get_property_name() ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: white; border: 1px !important;', $processor->get_updated_style() ); + } + /** * @covers ::append_declaration * @covers ::get_updated_style