From d9a0181f72aee5e7ae65a725f35b036c21da2d05 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:19:10 +0200 Subject: [PATCH 01/21] Add HTML style attribute processor --- ...lass-wp-html-style-attribute-processor.php | 938 ++++++++++++++++++ src/wp-settings.php | 1 + .../wpHtmlStyleAttributeProcessor.php | 274 +++++ 3 files changed, 1213 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 6dac160973467..d4bbf317eb22b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -281,6 +281,7 @@ require ABSPATH . WPINC . '/css-api/class-wp-css-token.php'; require ABSPATH . WPINC . '/css-api/class-wp-css-token-processor.php'; require ABSPATH . WPINC . '/css-api/class-wp-css-token-serializer.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..e6f5fcdef0372 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -0,0 +1,274 @@ +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 ::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 787ce336fd478e7d3e77a0971ab375943e322e1c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:34:12 +0200 Subject: [PATCH 02/21] 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 e6f5fcdef0372..327fac293a60a 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,38 @@ 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 ::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 @@ -185,6 +244,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 @@ -243,6 +320,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 a91b7e499a4b9a9f528bf5c80451b61cc92fd70e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 13:49:50 +0200 Subject: [PATCH 03/21] 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 327fac293a60a..b7d825d5fd1b6 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 02ccb51f70cf720ab531cbc02d4eaacfbdbb7544 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 14:14:51 +0200 Subject: [PATCH 04/21] 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 b7d825d5fd1b6..7f4d108f6773f 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 c5e8fcd55423e9816dfcf979b4db8853057863bf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 14:22:43 +0200 Subject: [PATCH 05/21] 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 7f4d108f6773f..c85105419eeaf 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 36da63ae06dd8c56ab50aa18bea6e3c6dafcdda7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Jun 2026 16:20:24 +0200 Subject: [PATCH 06/21] 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 c85105419eeaf..40b986f326594 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 b7c0f685a5bb1aa29c73fbacc00fe841d2dc6915 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 00:42:51 +0200 Subject: [PATCH 07/21] 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 9ebe88df9dfacd142284b2b49c9088b6f48ecc46 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 01:33:44 +0200 Subject: [PATCH 08/21] 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 46ca9fa41a91cadd6735024e1ac136fb7307cbfa Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 30 Jun 2026 01:33:52 +0200 Subject: [PATCH 09/21] Implement style attribute processor contract --- ...lass-wp-html-style-attribute-processor.php | 520 +++++++++++++++--- .../wpHtmlStyleAttributeProcessor.php | 510 ++++++++++++----- 2 files changed, 821 insertions(+), 209 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 40b986f326594..e6a22227b4a1b 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,11 +560,10 @@ 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() ); } @@ -360,7 +573,7 @@ public function test_stray_right_brace_consumes_bad_declaration_until_semicolon( * @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() ); @@ -369,69 +582,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 @@ -439,20 +652,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() ) ); @@ -465,29 +704,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 ), ); } @@ -508,15 +745,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 d3219771f10f862356d252efc7b60a5ba8abb828 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 26 Aug 2026 14:43:24 +0400 Subject: [PATCH 10/21] 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 e6a22227b4a1b..1176ff701b959 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 From 4cbb6300d1460f6880246435753d57de14efd7e5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 6 Oct 2026 13:12:54 +0400 Subject: [PATCH 11/21] Reject declarations CSS drops at parse time --- .../wp-html-style-attribute-processor-spec.md | 18 ++++- ...lass-wp-html-style-attribute-processor.php | 69 +++++++++++++++++++ .../wpHtmlStyleAttributeProcessor.php | 54 ++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 3728c39f520e0..3f9c40ad4631e 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -224,7 +224,23 @@ over whitespace preservation. 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. +not expose it as a declaration. That includes a declaration whose value, apart +from its priority, contains: + +- a bad string or a bad URL; +- a closing `)`, `]`, or `}` that does not match the innermost open block; +- a top-level `!`; +- a top-level `{}` block alongside other tokens, unless the property is a custom + property. + +Blocks and functions left open at EOF are closed by CSS, so a value ending +inside one is exposed. + +Splitting follows CSS Style Attributes and the published CSS Syntax Level 3 +"parse a list of declarations": a stray `}` is an invalid token, and the +invalid declaration runs to the next top-level semicolon. The CSS Syntax editor's +draft ends parsing at a top-level `}` instead; browsers and web-platform-tests do +not, and csswg-drafts#11113 tracks the conflict. Ignored invalid fragments are preserved where possible. Mutations touching a style containing invalid chunks must verify that the declaration-list structure 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 52b990ccee1f6..77a3c429cb2bc 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 @@ -742,6 +742,10 @@ private function parse_declaration_segment( int $leading_start, int $start_index $value_end = $this->tokens[ $value_end_index - 1 ]['end']; } + if ( ! $this->is_parsed_value_valid( $value_start_index, $value_end_index, $this->is_custom_property_name( $name ) ) ) { + return null; + } + return array( 'name' => $name, 'raw_name' => substr( $this->style, $name_token['start'], $name_token['length'] ), @@ -1397,6 +1401,71 @@ private function matches_property_name( string $actual, string $query ): bool { return 0 === strcasecmp( $actual, $query ); } + /** + * Checks whether CSS keeps a parsed declaration value, excluding its priority. + * + * CSS ignores a declaration whose value contains a bad string, a bad URL, an + * unmatched closing token, or a top-level `!`. A `{}` block must be the whole + * value unless the property is a custom property. Blocks left open at EOF are + * closed by CSS and are valid. + * + * @param int $start First value token index. + * @param int $end Token index after the value. + * @param bool $is_custom_property Whether the declaration is a custom property. + * @return bool Whether CSS keeps the value. + */ + private function is_parsed_value_valid( int $start, int $end, bool $is_custom_property ): bool { + $stack = array(); + $has_top_level_block = false; + $has_other_top_level = false; + + for ( $index = $start; $index < $end; $index++ ) { + $token = $this->tokens[ $index ]; + $type = $token['type']; + + if ( empty( $stack ) && ! $this->is_ignored_token( $token ) ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $type ) { + $has_top_level_block = true; + } else { + $has_other_top_level = true; + } + + if ( WP_CSS_Token_Processor::TOKEN_DELIM === $type && '!' === $token['value'] ) { + return false; + } + } + + switch ( $type ) { + 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 ) !== $type ) { + return false; + } + break; + } + } + + return $is_custom_property || ! ( $has_top_level_block && $has_other_top_level ); + } + /** * Checks whether a property name is a custom property. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 1176ff701b959..ab45dbc91e541 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -600,6 +600,35 @@ public function test_stray_right_brace_consumes_bad_declaration_until_semicolon( $this->assertFalse( $processor->next_declaration() ); } + /** + * @covers ::next_declaration + * @covers ::get_updated_style + * + * @dataProvider data_declarations_with_values_css_ignores + * + * @param string $style Style attribute value whose first declaration CSS ignores. + */ + public function test_declarations_with_values_css_ignores_are_skipped_and_preserved( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + + /** + * @covers ::next_declaration + */ + public function test_custom_property_values_may_mix_blocks_with_other_tokens() { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: { a } b; color: red;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( '--x', $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + /** * @covers ::next_declaration * @covers ::set_value @@ -757,7 +786,30 @@ public static function data_important_priority_syntax(): array { '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 ), + 'nested important' => array( 'color: var(--x, red !important);', false ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_declarations_with_values_css_ignores(): array { + return array( + 'bad string' => array( "font-family: \"a\n; color: red;" ), + 'bad url' => array( 'background: url(a b); color: red;' ), + 'nested bad url' => array( 'background: image-set(url(a b) 1x); color: red;' ), + 'unmatched right paren' => array( 'width: 1px ); color: red;' ), + 'unmatched right bracket' => array( 'width: 1px ]; color: red;' ), + 'unmatched right brace' => array( 'width: 1px }; color: red;' ), + 'mismatched closer in function' => array( 'width: calc(1px } + 2px); color: red;' ), + 'custom property unmatched brace' => array( '--x: a } b; color: red;' ), + 'bang before important' => array( 'padding: 0 ! 1px !important; color: red;' ), + 'important before other tokens' => array( 'padding: 0 !important 1px; color: red;' ), + 'custom property bang' => array( '--x: a ! b; color: red;' ), + 'block mixed with other tokens' => array( 'width: 1px { x }; color: red;' ), + 'block before other tokens' => array( 'width: { x } 1px; color: red;' ), ); } From 2625c51bcbb194d838b88c9c46e32504f6d348e4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 6 Oct 2026 15:10:33 +0400 Subject: [PATCH 12/21] Set style attribute processor @since to 7.2.0 --- ...lass-wp-html-style-attribute-processor.php | 24 +++++++++---------- .../wpHtmlStyleAttributeProcessor.php | 2 +- 2 files changed, 13 insertions(+), 13 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 77a3c429cb2bc..1ee0bc79dc6f9 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 @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage HTML-API - * @since {WP_VERSION} + * @since 7.2.0 */ /** @@ -26,7 +26,7 @@ * declaration list model of CSS. Multiple declarations with the same property * name remain separate entries and can be inspected or changed individually. * - * @since {WP_VERSION} + * @since 7.2.0 */ class WP_HTML_Style_Attribute_Processor { /** @@ -98,7 +98,7 @@ class WP_HTML_Style_Attribute_Processor { * Do not instantiate directly. Use * {@see WP_HTML_Style_Attribute_Processor::create()} instead. * - * @since {WP_VERSION} + * @since 7.2.0 * * @param string $style Decoded CSS text value from a style attribute. */ @@ -109,7 +109,7 @@ private function __construct( string $style ) { /** * Creates a processor for decoded CSS text from a style attribute. * - * @since {WP_VERSION} + * @since 7.2.0 * * @param string $decoded_css_text Decoded CSS text value from a style attribute. * @return static Created processor. @@ -128,7 +128,7 @@ 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} + * @since 7.2.0 * * @param string|null $property_name Optional declaration property name to match. * @return bool Whether a matching declaration was found. @@ -155,7 +155,7 @@ public function next_declaration( ?string $property_name = null ): bool { /** * Gets the current declaration's property name. * - * @since {WP_VERSION} + * @since 7.2.0 * * @return string|null Property name, or null when not on a declaration. */ @@ -167,7 +167,7 @@ public function get_property_name(): ?string { /** * Indicates whether the current declaration has an !important priority. * - * @since {WP_VERSION} + * @since 7.2.0 * * @return bool|null Whether the current declaration has an !important priority, * or null when not on a declaration. @@ -180,7 +180,7 @@ public function is_important(): ?bool { /** * Sets the value of the current declaration. * - * @since {WP_VERSION} + * @since 7.2.0 * * @param string $value CSS declaration value, without the property name. * @param bool|null $important Optional. Whether the declaration should be important. @@ -236,7 +236,7 @@ public function set_value( string $value, ?bool $important = null ): bool { /** * Sets the !important priority of the current declaration. * - * @since {WP_VERSION} + * @since 7.2.0 * * @param bool $important Whether the declaration should be important. * @return bool Whether the current declaration priority is set as requested. @@ -333,7 +333,7 @@ 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} + * @since 7.2.0 * * @return bool Whether the current declaration was removed. */ @@ -369,7 +369,7 @@ public function remove_declaration(): bool { * Appending does not remove or replace existing declarations with the same * property name. * - * @since {WP_VERSION} + * @since 7.2.0 * * @param string $property_name CSS property name. * @param string $value CSS declaration value, without the property name. @@ -431,7 +431,7 @@ public function append_declaration( string $property_name, string $value, bool $ /** * Returns the updated decoded CSS text value for the style attribute. * - * @since {WP_VERSION} + * @since 7.2.0 * * @return string Updated decoded CSS text value for the style attribute. */ diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index ab45dbc91e541..43d9cb9af9dd2 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -5,7 +5,7 @@ * @package WordPress * @subpackage HTML-API * - * @since {WP_VERSION} + * @since 7.2.0 * * @group html-api * From 00f4b82982fdb2a506a4de97a0c16c47b5aaf695 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 19:01:49 +0400 Subject: [PATCH 13/21] Expose declaration values as a token view --- .../wp-html-style-attribute-processor-spec.md | 46 +++- ...lass-wp-html-style-attribute-processor.php | 57 ++++- .../wpHtmlStyleAttributeProcessor.php | 224 ++++++++++++++++++ 3 files changed, 313 insertions(+), 14 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 3f9c40ad4631e..a22468b6f543e 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -51,7 +51,7 @@ The internal declaration data consists of: - decoded property name; - authored property-name source when available; -- value component list source range; +- value component list source range and token index range; - `!important` flag; - nullable source range for the parsed priority span; - source ranges needed for safe mutation and verification. @@ -67,6 +67,7 @@ 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; +public function get_value_tokens(): ?array; public function set_value( string $value, ?bool $important = null ): bool; public function set_important( bool $important ): bool; public function remove_declaration(): bool; @@ -74,9 +75,10 @@ public function append_declaration( string $property_name, string $value, 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. +The processor exposes a declaration value as tokens, through +`get_value_tokens()`, and not as a string: there is no `get_value()` or +`get_raw_value()`. Consumers that check or re-serialize values need tokens; a +string would be re-tokenized by every consumer. Bookmarks, seek, and rewind are out of scope. Callers that need to rescan should create a new processor from the updated style text. @@ -86,7 +88,7 @@ create a new processor from the updated style text. `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()`. +on a valid declaration. This includes `is_important()` and `get_value_tokens()`. After `remove_declaration()` succeeds, the cursor becomes invalid until `next_declaration()` advances it. A second removal on the same invalid cursor @@ -129,6 +131,38 @@ Appended property names must be valid plaintext CSS property names. The processo does not escape arbitrary strings into identifiers. Custom properties follow the same validity rule with the required `--` prefix. +## Value Tokens + +`get_value_tokens()` returns the current declaration's value as a list of CSS +tokens, or `null` when the cursor is not on a declaration. Each token is an +array with the keys `type`, `value`, `start`, `length`, and `end`: + +- `type` is a `WP_CSS_Token_Processor` `TOKEN_*` constant; +- `value` is the token value as `WP_CSS_Token_Processor::get_token_value()` + returns it: decoded, a dimension's number without its unit, a hash's name + without `#`, and `null` for tokens without a value; +- `start` and `length` are the token's byte offset and byte length in the + style text, and `end` is `start + length`. + +The list covers the value only. Leading and trailing whitespace and comments +and the `!important` priority are excluded; whitespace and comments between +value tokens are included. A declaration with no value tokens, such as +`color:;`, returns an empty list. + +Offsets index the current style text, which `get_updated_style()` returns. +Before any mutation that is the text passed to `create()`. A successful +mutation reparses the updated text, and the offsets follow it. + +Re-tokenizing the source slice from the first token's `start` to the last +token's `end` with `WP_CSS_Token_Processor` yields the same `type` and `value` +sequence. Consumers may rely on this to re-serialize a value or to hand the +slice to another tokenizer-based check. + +Two alternatives were considered. A `WP_CSS_Token_Processor` positioned over the +value's range would need range support in the tokenizer and give consumers a +forward-only cursor over tokens the processor already holds. A raw string would +be re-tokenized by every consumer. + ## Value Validation Mutation inputs accept CSS declaration values, not declaration-list text. @@ -287,6 +321,8 @@ Tests should cover: - declaration traversal, duplicate preservation, and property-name matching; - getter `null` behavior off-cursor; - absence of public raw value getter in the first API; +- value token view: `null` off-cursor, trivia and priority exclusion, offsets + into the style text, and the re-tokenization invariant; - `set_value()` priority preservation, setting, clearing, and empty-value removal; - `set_important()` setting, clearing, cursor behavior, and safe normalization; 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 1ee0bc79dc6f9..22b6ea40ef711 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 @@ -53,7 +53,7 @@ class WP_HTML_Style_Attribute_Processor { /** * Parsed declarations. * - * @var array + * @var array */ private $declarations = array(); @@ -177,6 +177,44 @@ public function is_important(): ?bool { return null === $declaration ? null : $declaration['important']; } + /** + * Gets the current declaration's value as a list of CSS tokens. + * + * The list covers the declaration value only. Leading and trailing + * whitespace and comments and the `!important` priority are excluded; + * whitespace and comments between value tokens are included. The list is + * empty when the declaration has no value tokens, as in `color:;`. + * + * Each token is an array with these keys: + * + * - `type`: a {@see WP_CSS_Token_Processor} `TOKEN_*` constant. + * - `value`: the token value as {@see WP_CSS_Token_Processor::get_token_value()} + * returns it: a dimension's number without its unit, a hash's name + * without `#`, a string's decoded contents, and null for tokens + * without a value. + * - `start`: byte offset of the token in the style text. + * - `length`: byte length of the token in the style text. + * - `end`: byte offset after the token, `start + length`. + * + * Offsets index the current style text, which + * {@see WP_HTML_Style_Attribute_Processor::get_updated_style()} returns. + * Before any mutation that is the text passed to `create()`. + * + * @since 7.2.0 + * + * @return array|null + * Value tokens, or null when not on a declaration. + */ + public function get_value_tokens(): ?array { + $declaration = $this->get_current_declaration(); + if ( null === $declaration ) { + return null; + } + + list( $start, $end ) = $declaration['value_tokens']; + return array_slice( $this->tokens, $start, $end - $start ); + } + /** * Sets the value of the current declaration. * @@ -686,7 +724,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, important_start:int|null, important_end:int|null}|null + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,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 ); @@ -755,6 +793,7 @@ private function parse_declaration_segment( int $leading_start, int $start_index 'trailing_end' => $after, 'value_start' => $value_start, 'value_end' => $value_end, + 'value_tokens' => array( $value_start_index, $value_end_index ), 'important' => $important, 'important_start' => $important_start, 'important_end' => $important_end, @@ -904,7 +943,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, important_start:int|null, important_end:int|null}|null + * @return array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,int}, important:bool, important_start:int|null, important_end:int|null}|null */ private function get_current_declaration(): ?array { if ( @@ -921,12 +960,12 @@ private function get_current_declaration(): ?array { /** * 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. + * @param array{name:string, raw_name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,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 { diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 43d9cb9af9dd2..87d61aa70cd3c 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -52,6 +52,7 @@ public function test_constructor_is_not_public() { 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' ) ); + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_value_source' ) ); } /** @@ -132,6 +133,124 @@ public function test_important_priority_syntax_variants( string $style, bool $im $this->assertSame( $important, $processor->is_important() ); } + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + */ + public function test_get_value_tokens_returns_null_when_not_on_a_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertNull( $processor->get_value_tokens(), 'Expected null before the first declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertNotNull( $processor->get_value_tokens() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + + $this->assertNull( $processor->get_value_tokens(), 'Expected null after the last declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * + * @dataProvider data_value_tokens + * + * @param string $style Style text. + * @param array $expected_tokens Expected value tokens. + */ + public function test_get_value_tokens_covers_only_the_declaration_value( string $style, array $expected_tokens ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $expected_tokens, $processor->get_value_tokens() ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + */ + public function test_get_value_tokens_decodes_values_and_keeps_source_offsets() { + $style = 'content: "a\\"b"'; + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $tokens = $processor->get_value_tokens(); + + $this->assertCount( 1, $tokens ); + $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[0]['type'] ); + $this->assertSame( 'a"b', $tokens[0]['value'] ); + $this->assertSame( '"a\\"b"', substr( $style, $tokens[0]['start'], $tokens[0]['length'] ) ); + $this->assertSame( $tokens[0]['start'] + $tokens[0]['length'], $tokens[0]['end'] ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * @covers ::set_value + * @covers ::remove_declaration + */ + public function test_get_value_tokens_follow_the_updated_style_after_mutations() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertSame( + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'green', 7, 5 ) ), + $processor->get_value_tokens() + ); + + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->get_value_tokens() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background: white', $processor->get_updated_style() ); + $this->assertSame( + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'white', 12, 5 ) ), + $processor->get_value_tokens() + ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * + * @dataProvider data_value_tokens_source_slices + * + * @param string $style Style text. + */ + public function test_get_value_tokens_match_the_retokenized_source_slice( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $tokens = $processor->get_value_tokens(); + $this->assertNotEmpty( $tokens ); + + $first = $tokens[0]; + $last = $tokens[ count( $tokens ) - 1 ]; + $slice = substr( $style, $first['start'], $last['end'] - $first['start'] ); + + $expected = array(); + $tokenizer = WP_CSS_Token_Processor::create( $slice ); + while ( $tokenizer->next_token() ) { + $expected[] = array( $tokenizer->get_token_type(), $tokenizer->get_token_value() ); + } + + $actual = array_map( + static function ( array $token ): array { + return array( $token['type'], $token['value'] ); + }, + $tokens + ); + + $this->assertSame( $expected, $actual ); + } + /** * @covers ::set_value * @covers ::get_updated_style @@ -335,6 +454,7 @@ public function test_remove_declaration_removes_adjacent_duplicate_declarations( * @covers ::remove_declaration * @covers ::get_property_name * @covers ::is_important + * @covers ::get_value_tokens */ public function test_getters_return_null_after_removing_current_declaration_until_cursor_advances() { $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); @@ -344,6 +464,7 @@ public function test_getters_return_null_after_removing_current_declaration_unti $this->assertNull( $processor->get_property_name() ); $this->assertNull( $processor->is_important() ); + $this->assertNull( $processor->get_value_tokens() ); $this->assertFalse( $processor->remove_declaration() ); $this->assertTrue( $processor->next_declaration() ); @@ -763,6 +884,109 @@ public function test_updated_style_can_be_set_on_html_tag_processor_after_string ); } + /** + * Builds a value token array. + * + * @param string $type Token type. + * @param string|null $value Token value. + * @param int $start Byte offset of the token. + * @param int $length Byte length of the token. + * @return array{type:string, value:string|null, start:int, length:int, end:int} + */ + private static function token( string $type, ?string $value, int $start, int $length ): array { + return array( + 'type' => $type, + 'value' => $value, + 'start' => $start, + 'length' => $length, + 'end' => $start + $length, + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_value_tokens(): array { + return array( + 'single ident' => array( + 'color: red', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'function with nested tokens' => array( + 'width: calc(1px + 2%)', + array( + self::token( WP_CSS_Token_Processor::TOKEN_FUNCTION, 'calc', 7, 5 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 12, 3 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 15, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_DELIM, '+', 16, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 17, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_PERCENTAGE, '2', 18, 2 ), + self::token( WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, null, 20, 1 ), + ), + ), + 'hash without number sign' => array( + 'color: #fff', + array( self::token( WP_CSS_Token_Processor::TOKEN_HASH, 'fff', 7, 4 ) ), + ), + 'important excluded' => array( + 'color: red !important', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'important without space excluded' => array( + 'color: red!important;', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'leading and trailing trivia' => array( + "color: /* a */ \t red /* b */ ;", + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 17, 3 ) ), + ), + 'trivia inside the value kept' => array( + 'margin: 1px /* a */ 2px', + array( + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 8, 3 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 11, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_COMMENT, null, 12, 7 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 19, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '2', 20, 3 ), + ), + ), + 'custom property' => array( + '--x: 1 2 3', + array( + self::token( WP_CSS_Token_Processor::TOKEN_NUMBER, '1', 5, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 6, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_NUMBER, '2', 7, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 8, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_NUMBER, '3', 9, 1 ), + ), + ), + 'empty value' => array( 'color:;', array() ), + 'only important' => array( 'color: !important', array() ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_value_tokens_source_slices(): array { + return array( + 'single ident' => array( 'color: red' ), + 'function' => array( 'width: calc(1px + 2%)' ), + 'string with escape' => array( 'content: "a\\"b"' ), + 'trivia and important' => array( 'color: /* a */ red /* b */ !important;' ), + 'custom property' => array( '--x: 1 2 3' ), + 'custom property block' => array( '--x: { a } b' ), + 'comma list' => array( 'font: 12px/1.5 "Foo Bar", sans-serif' ), + 'urls' => array( 'background: url(a.png) no-repeat, image-set("b.png" 2x)' ), + 'unclosed function' => array( 'color: var(--x' ), + 'eof escape' => array( 'color: red\\' ), + ); + } + /** * Data provider. * From cf1b73cbe32517ba681e2eae3a5404556db27588 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 23:31:52 +0400 Subject: [PATCH 14/21] Include the dimension unit in the value token view --- .../wp-html-style-attribute-processor-spec.md | 9 ++++-- ...lass-wp-html-style-attribute-processor.php | 8 +++-- .../wpHtmlStyleAttributeProcessor.php | 31 ++++++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index a22468b6f543e..f0e800b625337 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -135,12 +135,15 @@ same validity rule with the required `--` prefix. `get_value_tokens()` returns the current declaration's value as a list of CSS tokens, or `null` when the cursor is not on a declaration. Each token is an -array with the keys `type`, `value`, `start`, `length`, and `end`: +array with the keys `type`, `value`, `unit`, `start`, `length`, and `end`: - `type` is a `WP_CSS_Token_Processor` `TOKEN_*` constant; - `value` is the token value as `WP_CSS_Token_Processor::get_token_value()` returns it: decoded, a dimension's number without its unit, a hash's name without `#`, and `null` for tokens without a value; +- `unit` is the decoded unit of a dimension token, as + `WP_CSS_Token_Processor::get_token_unit()` returns it, and `null` for every + other token type; - `start` and `length` are the token's byte offset and byte length in the style text, and `end` is `start + length`. @@ -154,8 +157,8 @@ Before any mutation that is the text passed to `create()`. A successful mutation reparses the updated text, and the offsets follow it. Re-tokenizing the source slice from the first token's `start` to the last -token's `end` with `WP_CSS_Token_Processor` yields the same `type` and `value` -sequence. Consumers may rely on this to re-serialize a value or to hand the +token's `end` with `WP_CSS_Token_Processor` yields the same `type`, `value` and +`unit` sequence. Consumers may rely on this to re-serialize a value or to hand the slice to another tokenizer-based check. Two alternatives were considered. A `WP_CSS_Token_Processor` positioned over the 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 22b6ea40ef711..b7bf65dd6ee46 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 @@ -46,7 +46,7 @@ class WP_HTML_Style_Attribute_Processor { /** * CSS token metadata. * - * @var array + * @var array */ private $tokens = array(); @@ -192,6 +192,9 @@ public function is_important(): ?bool { * returns it: a dimension's number without its unit, a hash's name * without `#`, a string's decoded contents, and null for tokens * without a value. + * - `unit`: the decoded unit of a dimension token, as + * {@see WP_CSS_Token_Processor::get_token_unit()} returns it, and null + * for every other token type. * - `start`: byte offset of the token in the style text. * - `length`: byte length of the token in the style text. * - `end`: byte offset after the token, `start + length`. @@ -202,7 +205,7 @@ public function is_important(): ?bool { * * @since 7.2.0 * - * @return array|null + * @return array|null * Value tokens, or null when not on a declaration. */ public function get_value_tokens(): ?array { @@ -524,6 +527,7 @@ private function parse(): void { $this->tokens[] = array( 'type' => $processor->get_token_type(), 'value' => $processor->get_token_value(), + 'unit' => $processor->get_token_unit(), 'start' => $start, 'length' => $length, 'end' => $start + $length, diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 87d61aa70cd3c..526228e63f07c 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -238,12 +238,12 @@ public function test_get_value_tokens_match_the_retokenized_source_slice( string $expected = array(); $tokenizer = WP_CSS_Token_Processor::create( $slice ); while ( $tokenizer->next_token() ) { - $expected[] = array( $tokenizer->get_token_type(), $tokenizer->get_token_value() ); + $expected[] = array( $tokenizer->get_token_type(), $tokenizer->get_token_value(), $tokenizer->get_token_unit() ); } $actual = array_map( static function ( array $token ): array { - return array( $token['type'], $token['value'] ); + return array( $token['type'], $token['value'], $token['unit'] ); }, $tokens ); @@ -891,12 +891,13 @@ public function test_updated_style_can_be_set_on_html_tag_processor_after_string * @param string|null $value Token value. * @param int $start Byte offset of the token. * @param int $length Byte length of the token. - * @return array{type:string, value:string|null, start:int, length:int, end:int} + * @return array{type:string, value:string|null, unit:string|null, start:int, length:int, end:int} */ - private static function token( string $type, ?string $value, int $start, int $length ): array { + private static function token( string $type, ?string $value, int $start, int $length, ?string $unit = null ): array { return array( 'type' => $type, 'value' => $value, + 'unit' => $unit, 'start' => $start, 'length' => $length, 'end' => $start + $length, @@ -918,7 +919,7 @@ public static function data_value_tokens(): array { 'width: calc(1px + 2%)', array( self::token( WP_CSS_Token_Processor::TOKEN_FUNCTION, 'calc', 7, 5 ), - self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 12, 3 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 12, 3, 'px' ), self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 15, 1 ), self::token( WP_CSS_Token_Processor::TOKEN_DELIM, '+', 16, 1 ), self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 17, 1 ), @@ -945,11 +946,11 @@ public static function data_value_tokens(): array { 'trivia inside the value kept' => array( 'margin: 1px /* a */ 2px', array( - self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 8, 3 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 8, 3, 'px' ), self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 11, 1 ), self::token( WP_CSS_Token_Processor::TOKEN_COMMENT, null, 12, 7 ), self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 19, 1 ), - self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '2', 20, 3 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '2', 20, 3, 'px' ), ), ), 'custom property' => array( @@ -962,6 +963,22 @@ public static function data_value_tokens(): array { self::token( WP_CSS_Token_Processor::TOKEN_NUMBER, '3', 9, 1 ), ), ), + 'dimension unit' => array( + 'width: 10px', + array( self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '10', 7, 4, 'px' ) ), + ), + 'dimension unit decoded' => array( + 'width: 1p\\78', + array( self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 7, 5, 'px' ) ), + ), + 'number has no unit' => array( + '--x: 10', + array( self::token( WP_CSS_Token_Processor::TOKEN_NUMBER, '10', 5, 2 ) ), + ), + 'percentage has no unit' => array( + 'width: 10%', + array( self::token( WP_CSS_Token_Processor::TOKEN_PERCENTAGE, '10', 7, 3 ) ), + ), 'empty value' => array( 'color:;', array() ), 'only important' => array( 'color: !important', array() ), ); From a2ea2a4c93d1950e3911c5b444ee6aad23f6d5b6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 12:28:32 +0400 Subject: [PATCH 15/21] Trim declaration values by token, not by byte set_value() and append_declaration() trimmed CSS whitespace bytes from the end of the value. When the last token is an escape of a space or tab, or a backslash delimiter followed by a newline, the trim removed the byte that completed it, so the `;` written after the value became part of an escape: append_declaration( '--x', "a\\ " ) wrote `--x: a\;`, whose value is the ident `a;`. set_value() rejected the same values because its replacement check saw the declaration absorb the next one. Trim only whitespace tokens, and keep one newline after a final backslash delimiter. --- ...lass-wp-html-style-attribute-processor.php | 30 +++++++++++-- .../wpHtmlStyleAttributeProcessor.php | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 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 b7bf65dd6ee46..849c45fdbbff1 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 @@ -243,7 +243,7 @@ public function set_value( string $value, ?bool $important = null ): bool { } $is_important = null === $important ? $declaration['important'] : $important; - $text = $this->serialize_declaration( $declaration['raw_name'], $value, $is_important ); + $text = $this->serialize_declaration( $declaration['raw_name'], $this->trim_value( $value ), $is_important ); $parsed_value = $this->get_parsed_declaration_value( $text ); if ( null === $parsed_value ) { return false; @@ -439,7 +439,7 @@ public function append_declaration( string $property_name, string $value, bool $ $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 ); + $declaration_text = $this->serialize_declaration( $serialized_name, $this->trim_value( $value ), $important ); $parsed_value = $this->get_parsed_declaration_value( $declaration_text ); if ( null === $parsed_value ) { return false; @@ -1425,10 +1425,34 @@ private function merge_overlapping_lexical_updates( array $updates ): array { * @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' : '' ) . ';'; } + /** + * Removes the whitespace tokens before and after a declaration value. + * + * Whitespace consumed by an escape is part of the value, and a `\` before + * a newline keeps one newline so that it does not escape the next byte. + * + * @param string $value CSS declaration value. + * @return string Trimmed value. + */ + private function trim_value( string $value ): string { + $value = ltrim( $value, self::WHITESPACE ); + $processor = WP_CSS_Token_Processor::create( $value ); + $end = 0; + $is_delim = false; + + while ( $processor->next_token() ) { + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $processor->get_token_type() ) { + $end = $processor->get_token_start() + $processor->get_token_length(); + $is_delim = WP_CSS_Token_Processor::TOKEN_DELIM === $processor->get_token_type() && '\\' === $processor->get_token_value(); + } + } + + return substr( $value, 0, $end ) . ( $is_delim ? "\n" : '' ); + } + /** * Checks whether two property names match. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 526228e63f07c..9d1a667f8a1e4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -279,6 +279,48 @@ public function test_declaration_values_allow_leading_and_trailing_comments_as_t $this->assertSame( 'color: /* before */ green /* after */; background: /* before */ white /* after */;', $processor->get_updated_style() ); } + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_values_ending_in_escaped_whitespace_or_a_backslash_delimiter + * + * @param string $value Declaration value. + * @param string $written Expected value text in the updated style. + */ + public function test_trimming_a_value_keeps_its_final_escape_or_backslash_delimiter( string $value, string $written ) { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: 1; width: 1px' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( $value ), 'Expected set_value() to succeed.' ); + $this->assertSame( "--x: {$written}; width: 1px", $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: 1px' ); + + $this->assertTrue( $processor->append_declaration( '--x', $value ), 'Expected append_declaration() to succeed.' ); + $this->assertSame( "width: 1px; --x: {$written};", $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_values_ending_in_escaped_whitespace_or_a_backslash_delimiter() { + return array( + 'escaped space' => array( 'a\\ ', 'a\\ ' ), + 'escaped space, then whitespace' => array( "a\\ \t", 'a\\ ' ), + 'escaped tab' => array( "a\\\t", "a\\\t" ), + 'backslash before a newline' => array( "a\\\n", "a\\\n" ), + 'backslash, newline, then spaces' => array( "a\\\n ", "a\\\n" ), + 'escaped backslash' => array( 'a\\\\ ', 'a\\\\' ), + 'escaped backslash, then a delim' => array( "a\\\\\\\n", "a\\\\\\\n" ), + ); + } + /** * @covers ::set_value * @covers ::is_important From 02465b54f5fd7ad3b71822811a754ac888a94b32 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 12:30:29 +0400 Subject: [PATCH 16/21] Store WP_CSS_Token objects for the style attribute The processor kept an array per token with the type, the decoded value and unit, and the offsets, and get_value_tokens() returned those arrays. It now stores the WP_CSS_Token snapshots from WP_CSS_Token_Processor::get_token() and returns them, so values are decoded when read rather than for every token during parsing, and consumers read the flag and termination that the arrays did not carry. Declarations stay private arrays, and the processor uses the token processor rather than extending it. --- .../wp-html-style-attribute-processor-spec.md | 26 +-- ...lass-wp-html-style-attribute-processor.php | 176 ++++++++---------- .../wpHtmlStyleAttributeProcessor.php | 41 ++-- 3 files changed, 117 insertions(+), 126 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index f0e800b625337..3db6fc3f679cd 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -133,19 +133,11 @@ same validity rule with the required `--` prefix. ## Value Tokens -`get_value_tokens()` returns the current declaration's value as a list of CSS -tokens, or `null` when the cursor is not on a declaration. Each token is an -array with the keys `type`, `value`, `unit`, `start`, `length`, and `end`: - -- `type` is a `WP_CSS_Token_Processor` `TOKEN_*` constant; -- `value` is the token value as `WP_CSS_Token_Processor::get_token_value()` - returns it: decoded, a dimension's number without its unit, a hash's name - without `#`, and `null` for tokens without a value; -- `unit` is the decoded unit of a dimension token, as - `WP_CSS_Token_Processor::get_token_unit()` returns it, and `null` for every - other token type; -- `start` and `length` are the token's byte offset and byte length in the - style text, and `end` is `start + length`. +`get_value_tokens()` returns the current declaration's value as a list of +`WP_CSS_Token` objects, or `null` when the cursor is not on a declaration. A +token's getters give its type (a `WP_CSS_Token_Processor` `TOKEN_*` constant), +type flag, decoded value, decoded dimension unit, byte offset and byte length in +the style text, and whether it ended at its closing delimiter. The list covers the value only. Leading and trailing whitespace and comments and the `!important` priority are excluded; whitespace and comments between @@ -156,10 +148,10 @@ Offsets index the current style text, which `get_updated_style()` returns. Before any mutation that is the text passed to `create()`. A successful mutation reparses the updated text, and the offsets follow it. -Re-tokenizing the source slice from the first token's `start` to the last -token's `end` with `WP_CSS_Token_Processor` yields the same `type`, `value` and -`unit` sequence. Consumers may rely on this to re-serialize a value or to hand the -slice to another tokenizer-based check. +Re-tokenizing the source slice from the first token's start to the end of the +last token with `WP_CSS_Token_Processor` yields the same type, flag, value, +unit and termination sequence. Consumers may rely on this to re-serialize a +value or to hand the slice to another tokenizer-based check. Two alternatives were considered. A `WP_CSS_Token_Processor` positioned over the value's range would need range support in the tokenizer and give consumers a 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 849c45fdbbff1..bbebaea8aa1dd 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 @@ -44,9 +44,9 @@ class WP_HTML_Style_Attribute_Processor { private $style; /** - * CSS token metadata. + * CSS tokens of the style attribute value. * - * @var array + * @var WP_CSS_Token[] */ private $tokens = array(); @@ -185,28 +185,13 @@ public function is_important(): ?bool { * whitespace and comments between value tokens are included. The list is * empty when the declaration has no value tokens, as in `color:;`. * - * Each token is an array with these keys: - * - * - `type`: a {@see WP_CSS_Token_Processor} `TOKEN_*` constant. - * - `value`: the token value as {@see WP_CSS_Token_Processor::get_token_value()} - * returns it: a dimension's number without its unit, a hash's name - * without `#`, a string's decoded contents, and null for tokens - * without a value. - * - `unit`: the decoded unit of a dimension token, as - * {@see WP_CSS_Token_Processor::get_token_unit()} returns it, and null - * for every other token type. - * - `start`: byte offset of the token in the style text. - * - `length`: byte length of the token in the style text. - * - `end`: byte offset after the token, `start + length`. - * - * Offsets index the current style text, which + * Token offsets index the current style text, which * {@see WP_HTML_Style_Attribute_Processor::get_updated_style()} returns. * Before any mutation that is the text passed to `create()`. * * @since 7.2.0 * - * @return array|null - * Value tokens, or null when not on a declaration. + * @return WP_CSS_Token[]|null Value tokens, or null when not on a declaration. */ public function get_value_tokens(): ?array { $declaration = $this->get_current_declaration(); @@ -522,16 +507,7 @@ private function parse(): void { } 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(), - 'unit' => $processor->get_token_unit(), - 'start' => $start, - 'length' => $length, - 'end' => $start + $length, - ); + $this->tokens[] = $processor->get_token(); } $this->parse_declaration_list(); @@ -561,8 +537,8 @@ private function parse_declaration_list(): void { $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']; + while ( $index < $count && WP_CSS_Token_Processor::TOKEN_SEMICOLON === $this->tokens[ $index ]->get_type() ) { + $previous_item_ends_at = self::get_token_end( $this->tokens[ $index ] ); $leading_start = $previous_item_ends_at; ++$index; $index = $this->skip_ignored_tokens( $index ); @@ -572,7 +548,7 @@ private function parse_declaration_list(): void { break; } - if ( WP_CSS_Token_Processor::TOKEN_AT_KEYWORD === $this->tokens[ $index ]['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_AT_KEYWORD === $this->tokens[ $index ]->get_type() ) { list( $index, $previous_item_ends_at ) = $this->consume_at_rule( $index ); continue; } @@ -603,25 +579,25 @@ private function consume_at_rule( int $index ): array { 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_SEMICOLON === $token->get_type() ) { + return array( $index + 1, self::get_token_end( $token ) ); } - if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token->get_type() ) { return $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); } - if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token->get_type() ) { list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET ); continue; } @@ -629,7 +605,7 @@ private function consume_at_rule( int $index ): array { ++$index; } - $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] ); return array( $count, $after ); } @@ -647,26 +623,26 @@ private function consume_simple_block( int $index, string $closing_type ): array while ( $index < $count ) { $token = $this->tokens[ $index ]; - if ( $closing_type === $token['type'] ) { - return array( $index + 1, $token['end'] ); + if ( $closing_type === $token->get_type() ) { + return array( $index + 1, self::get_token_end( $token ) ); } - if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token->get_type() ) { list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); continue; } @@ -674,7 +650,7 @@ private function consume_simple_block( int $index, string $closing_type ): array ++$index; } - $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] ); return array( $count, $after ); } @@ -690,26 +666,26 @@ private function consume_declaration_segment( int $index ): array { 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_SEMICOLON === $token->get_type() ) { + return array( $index, self::get_token_end( $token ), true ); } - if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token->get_type() ) { list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); continue; } @@ -717,7 +693,7 @@ private function consume_declaration_segment( int $index ): array { ++$index; } - $after = empty( $this->tokens ) ? strlen( $this->style ) : $this->tokens[ $count - 1 ]['end']; + $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] ); return array( $count, $after, false ); } @@ -732,12 +708,12 @@ private function consume_declaration_segment( int $index ): array { */ 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'] ) { + if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_IDENT !== $this->tokens[ $index ]->get_type() ) { return null; } $name_token = $this->tokens[ $index ]; - $name = $name_token['value']; + $name = $name_token->get_value(); if ( null === $name ) { return null; } @@ -747,7 +723,7 @@ private function parse_declaration_segment( int $leading_start, int $start_index ++$index; $index = $this->skip_ignored_tokens( $index, $end_index ); - if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_COLON !== $this->tokens[ $index ]['type'] ) { + if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_COLON !== $this->tokens[ $index ]->get_type() ) { return null; } @@ -765,23 +741,23 @@ private function parse_declaration_segment( int $leading_start, int $start_index 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'] + WP_CSS_Token_Processor::TOKEN_IDENT === $this->tokens[ $last_value_index ]->get_type() && + 0 === strcasecmp( 'important', (string) $this->tokens[ $last_value_index ]->get_value() ) && + WP_CSS_Token_Processor::TOKEN_DELIM === $this->tokens[ $before_last_index ]->get_type() && + '!' === $this->tokens[ $before_last_index ]->get_value() ) { $important = true; - $important_start = $this->tokens[ $before_last_index ]['start']; - $important_end = $this->tokens[ $last_value_index ]['end']; + $important_start = $this->tokens[ $before_last_index ]->get_start(); + $important_end = self::get_token_end( $this->tokens[ $last_value_index ] ); $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']; + $value_start = self::get_token_end( $colon_token ); + $value_end = self::get_token_end( $colon_token ); } else { - $value_start = $this->tokens[ $value_start_index ]['start']; - $value_end = $this->tokens[ $value_end_index - 1 ]['end']; + $value_start = $this->tokens[ $value_start_index ]->get_start(); + $value_end = self::get_token_end( $this->tokens[ $value_end_index - 1 ] ); } if ( ! $this->is_parsed_value_valid( $value_start_index, $value_end_index, $this->is_custom_property_name( $name ) ) ) { @@ -790,9 +766,9 @@ 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'] ), + 'raw_name' => substr( $this->style, $name_token->get_start(), $name_token->get_length() ), 'leading_start' => $leading_start, - 'start' => $name_token['start'], + 'start' => $name_token->get_start(), 'after' => $after, 'trailing_end' => $after, 'value_start' => $value_start, @@ -825,22 +801,22 @@ private function get_top_level_non_ignored_token_indexes( int $start, int $end ) $top_level_tokens[] = $index; - if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token->get_type() ) { list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); continue; } @@ -890,11 +866,11 @@ private function trim_ignored_tokens( int $start, int $end ): int { * @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; + $end = $index > 0 && isset( $this->tokens[ $index - 1 ] ) ? self::get_token_end( $this->tokens[ $index - 1 ] ) : 0; $count = count( $this->tokens ); while ( $index < $count && $this->is_ignored_token( $this->tokens[ $index ] ) ) { - $end = $this->tokens[ $index ]['end']; + $end = self::get_token_end( $this->tokens[ $index ] ); ++$index; } @@ -904,16 +880,26 @@ private function get_trailing_ignored_end( int $index ): int { /** * 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. + * @param WP_CSS_Token $token Token. * @return bool Whether the token is ignored. */ - private function is_ignored_token( array $token ): bool { + private function is_ignored_token( WP_CSS_Token $token ): bool { return ( - WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token['type'] || - WP_CSS_Token_Processor::TOKEN_COMMENT === $token['type'] + WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token->get_type() || + WP_CSS_Token_Processor::TOKEN_COMMENT === $token->get_type() ); } + /** + * Gets the byte offset after a token. + * + * @param WP_CSS_Token $token Token. + * @return int Byte offset after the token. + */ + private static function get_token_end( WP_CSS_Token $token ): int { + return $token->get_start() + $token->get_length(); + } + /** * Gets the byte offset before contiguous whitespace ending at an offset. * @@ -1161,16 +1147,16 @@ private function get_append_separator( int $insert_at ): string { return $insert_at > 0 ? ' ' : ''; } - return WP_CSS_Token_Processor::TOKEN_SEMICOLON === $last_token['type'] ? ' ' : '; '; + return WP_CSS_Token_Processor::TOKEN_SEMICOLON === $last_token->get_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. + * @return WP_CSS_Token|null Token, or null when there is none. */ - private function get_last_top_level_non_ignored_token_before( int $insert_at ): ?array { + private function get_last_top_level_non_ignored_token_before( int $insert_at ): ?WP_CSS_Token { $count = count( $this->tokens ); $index = 0; $last_token = null; @@ -1178,7 +1164,7 @@ private function get_last_top_level_non_ignored_token_before( int $insert_at ): while ( $index < $count ) { $token = $this->tokens[ $index ]; - if ( $token['start'] >= $insert_at ) { + if ( $token->get_start() >= $insert_at ) { break; } @@ -1189,22 +1175,22 @@ private function get_last_top_level_non_ignored_token_before( int $insert_at ): $last_token = $token; - if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token['type'] ) { + if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_PAREN === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET === $token->get_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'] ) { + if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $token->get_type() ) { list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ); continue; } @@ -1228,22 +1214,22 @@ private function get_eof_repair( int $insert_at ): ?string { $stack = array(); foreach ( $this->tokens as $token ) { - if ( $token['start'] >= $insert_at ) { + if ( $token->get_start() >= $insert_at ) { break; } - if ( $token['end'] > $insert_at ) { + if ( self::get_token_end( $token ) > $insert_at ) { return null; } - switch ( $token['type'] ) { + switch ( $token->get_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'] && + strlen( $this->style ) === self::get_token_end( $token ) && '*/' !== substr( $this->style, -2 ) ) { return null; @@ -1295,12 +1281,12 @@ private function get_eof_repair( int $insert_at ): ?string { /** * 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. + * @param WP_CSS_Token $token URL token. * @return bool Whether the URL token was closed in source. */ - private function is_url_token_closed( array $token ): bool { + private function is_url_token_closed( WP_CSS_Token $token ): bool { $sentinel = ' !wp-style-attribute-processor-sentinel'; - $source = substr( $this->style, $token['start'], $token['length'] ); + $source = substr( $this->style, $token->get_start(), $token->get_length() ); $scanner = WP_CSS_Token_Processor::create( $source . $sentinel ); if ( null === $scanner || ! $scanner->next_token() ) { return false; @@ -1488,7 +1474,7 @@ private function is_parsed_value_valid( int $start, int $end, bool $is_custom_pr for ( $index = $start; $index < $end; $index++ ) { $token = $this->tokens[ $index ]; - $type = $token['type']; + $type = $token->get_type(); if ( empty( $stack ) && ! $this->is_ignored_token( $token ) ) { if ( WP_CSS_Token_Processor::TOKEN_LEFT_BRACE === $type ) { @@ -1497,7 +1483,7 @@ private function is_parsed_value_valid( int $start, int $end, bool $is_custom_pr $has_other_top_level = true; } - if ( WP_CSS_Token_Processor::TOKEN_DELIM === $type && '!' === $token['value'] ) { + if ( WP_CSS_Token_Processor::TOKEN_DELIM === $type && '!' === $token->get_value() ) { return false; } } diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 9d1a667f8a1e4..81754d65cec75 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -165,7 +165,10 @@ public function test_get_value_tokens_covers_only_the_declaration_value( string $processor = WP_HTML_Style_Attribute_Processor::create( $style ); $this->assertTrue( $processor->next_declaration() ); - $this->assertSame( $expected_tokens, $processor->get_value_tokens() ); + $tokens = $processor->get_value_tokens(); + + $this->assertContainsOnlyInstancesOf( WP_CSS_Token::class, $tokens ); + $this->assertSame( $expected_tokens, array_map( array( self::class, 'token_fields' ), $tokens ) ); } /** @@ -181,10 +184,9 @@ public function test_get_value_tokens_decodes_values_and_keeps_source_offsets() $tokens = $processor->get_value_tokens(); $this->assertCount( 1, $tokens ); - $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[0]['type'] ); - $this->assertSame( 'a"b', $tokens[0]['value'] ); - $this->assertSame( '"a\\"b"', substr( $style, $tokens[0]['start'], $tokens[0]['length'] ) ); - $this->assertSame( $tokens[0]['start'] + $tokens[0]['length'], $tokens[0]['end'] ); + $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[0]->get_type() ); + $this->assertSame( 'a"b', $tokens[0]->get_value() ); + $this->assertSame( '"a\\"b"', substr( $style, $tokens[0]->get_start(), $tokens[0]->get_length() ) ); } /** @@ -201,7 +203,7 @@ public function test_get_value_tokens_follow_the_updated_style_after_mutations() $this->assertTrue( $processor->set_value( 'green' ) ); $this->assertSame( array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'green', 7, 5 ) ), - $processor->get_value_tokens() + array_map( array( self::class, 'token_fields' ), $processor->get_value_tokens() ) ); $this->assertTrue( $processor->remove_declaration() ); @@ -211,7 +213,7 @@ public function test_get_value_tokens_follow_the_updated_style_after_mutations() $this->assertSame( 'background: white', $processor->get_updated_style() ); $this->assertSame( array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'white', 12, 5 ) ), - $processor->get_value_tokens() + array_map( array( self::class, 'token_fields' ), $processor->get_value_tokens() ) ); } @@ -233,17 +235,18 @@ public function test_get_value_tokens_match_the_retokenized_source_slice( string $first = $tokens[0]; $last = $tokens[ count( $tokens ) - 1 ]; - $slice = substr( $style, $first['start'], $last['end'] - $first['start'] ); + $slice = substr( $style, $first->get_start(), $last->get_start() + $last->get_length() - $first->get_start() ); $expected = array(); $tokenizer = WP_CSS_Token_Processor::create( $slice ); while ( $tokenizer->next_token() ) { - $expected[] = array( $tokenizer->get_token_type(), $tokenizer->get_token_value(), $tokenizer->get_token_unit() ); + $token = $tokenizer->get_token(); + $expected[] = array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->is_terminated() ); } $actual = array_map( - static function ( array $token ): array { - return array( $token['type'], $token['value'], $token['unit'] ); + static function ( WP_CSS_Token $token ): array { + return array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->is_terminated() ); }, $tokens ); @@ -927,13 +930,14 @@ public function test_updated_style_can_be_set_on_html_tag_processor_after_string } /** - * Builds a value token array. + * Builds the expected fields of a value token. * * @param string $type Token type. * @param string|null $value Token value. * @param int $start Byte offset of the token. * @param int $length Byte length of the token. - * @return array{type:string, value:string|null, unit:string|null, start:int, length:int, end:int} + * @param string|null $unit Optional. Dimension unit. Default null. + * @return array{type:string, value:string|null, unit:string|null, start:int, length:int} */ private static function token( string $type, ?string $value, int $start, int $length, ?string $unit = null ): array { return array( @@ -942,10 +946,19 @@ private static function token( string $type, ?string $value, int $start, int $le 'unit' => $unit, 'start' => $start, 'length' => $length, - 'end' => $start + $length, ); } + /** + * Reads the fields of a value token that the expectations compare. + * + * @param WP_CSS_Token $token Value token. + * @return array{type:string, value:string|null, unit:string|null, start:int, length:int} + */ + private static function token_fields( WP_CSS_Token $token ): array { + return self::token( $token->get_type(), $token->get_value(), $token->get_start(), $token->get_length(), $token->get_unit() ); + } + /** * Data provider. * From b8bbf3a9fa2556c44a006beb51a8807ecb93bc0a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 12:40:00 +0400 Subject: [PATCH 17/21] Read token termination for the EOF repair get_eof_repair() decided whether a comment or url token at the end of the style was closed by reading bytes: a comment counted as closed when the style ended in `*/`, and a url token was re-tokenized with a sentinel appended. WP_CSS_Token::is_terminated() records the answer, so use it and delete is_url_token_closed(). The byte check counted `/*/` as a closed comment. Appending after it still failed, because the parse check rejects a declaration absorbed by the comment; the new test pins that result, which is the same before and after this change. --- ...lass-wp-html-style-attribute-processor.php | 31 ++----------------- .../wpHtmlStyleAttributeProcessor.php | 20 ++++++++++-- 2 files changed, 20 insertions(+), 31 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 bbebaea8aa1dd..75a3df2edc43f 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 @@ -1228,10 +1228,7 @@ private function get_eof_repair( int $insert_at ): ?string { return null; case WP_CSS_Token_Processor::TOKEN_COMMENT: - if ( - strlen( $this->style ) === self::get_token_end( $token ) && - '*/' !== substr( $this->style, -2 ) - ) { + if ( ! $token->is_terminated() ) { return null; } break; @@ -1242,7 +1239,7 @@ private function get_eof_repair( int $insert_at ): ?string { break; case WP_CSS_Token_Processor::TOKEN_URL: - if ( ! $this->is_url_token_closed( $token ) ) { + if ( ! $token->is_terminated() ) { $stack[] = ')'; } break; @@ -1278,30 +1275,6 @@ private function get_eof_repair( int $insert_at ): ?string { return implode( '', array_reverse( $stack ) ); } - /** - * Checks whether a URL token source contains its real closing parenthesis. - * - * @param WP_CSS_Token $token URL token. - * @return bool Whether the URL token was closed in source. - */ - private function is_url_token_closed( WP_CSS_Token $token ): bool { - $sentinel = ' !wp-style-attribute-processor-sentinel'; - $source = substr( $this->style, $token->get_start(), $token->get_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. * diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 81754d65cec75..e00f85b8e07c0 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -732,15 +732,31 @@ public function test_append_declaration_repairs_unclosed_eof_component_values() /** * @covers ::append_declaration * @covers ::get_updated_style + * + * @dataProvider data_styles_ending_inside_a_comment + * + * @param string $style Style text. */ - public function test_append_declaration_rejects_eof_repairs_that_cannot_be_precise() { - $style = 'color: var(--x;/*keep'; + public function test_append_declaration_rejects_eof_repairs_that_cannot_be_precise( string $style ) { $processor = WP_HTML_Style_Attribute_Processor::create( $style ); $this->assertFalse( $processor->append_declaration( 'background', 'white' ) ); $this->assertSame( $style, $processor->get_updated_style() ); } + /** + * Data provider. + * + * @return array + */ + public static function data_styles_ending_inside_a_comment(): array { + return array( + 'comment inside an open function' => array( 'color: var(--x;/*keep' ), + 'comment after a declaration' => array( 'color: red /* keep' ), + 'comment ending in a slash' => array( 'color: red /*/' ), + ); + } + /** * @covers ::next_declaration * @covers ::get_updated_style From e87aecfad7dd5ae29e18caad1ca7a43c9ecc6f90 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 13 Oct 2026 17:28:12 +0400 Subject: [PATCH 18/21] Add serialize_declaration() to the style attribute processor Code that reads a declaration and needs to write it back, such as a filter that keeps some declarations and drops others, had no public way to do so: WP_CSS_Token_Serializer is internal, and the processor wrote text only through mutations. serialize_declaration() returns the declaration at the cursor as `name: value;` or `name: value !important;`, as CSSOM writes a declaration, or null when the cursor is not on a declaration, like the other getters. The name is escaped as an identifier and the value is written by WP_CSS_Token_Serializer, so comments are dropped, whitespace runs become one space, and blocks left open at the end of the style are closed. The name follows WP_HTML_Processor::serialize_token(). The private formatter it shares with set_value() and append_declaration() is renamed format_declaration(). --- .../wp-html-style-attribute-processor-spec.md | 14 ++ ...lass-wp-html-style-attribute-processor.php | 42 +++++- .../wpHtmlStyleAttributeProcessor.php | 140 ++++++++++++++++++ 3 files changed, 189 insertions(+), 7 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 3db6fc3f679cd..9e96a1f4eb374 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -68,6 +68,7 @@ public function next_declaration( ?string $property_name = null ): bool; public function get_property_name(): ?string; public function is_important(): ?bool; public function get_value_tokens(): ?array; +public function serialize_declaration(): ?string; public function set_value( string $value, ?bool $important = null ): bool; public function set_important( bool $important ): bool; public function remove_declaration(): bool; @@ -158,6 +159,19 @@ value's range would need range support in the tokenizer and give consumers a forward-only cursor over tokens the processor already holds. A raw string would be re-tokenized by every consumer. +## Serializing A Declaration + +`serialize_declaration()` writes the current declaration from its parsed parts, +or returns `null` when the cursor is not on a declaration. The property name is +escaped as an identifier. The value is written from its tokens with +`WP_CSS_Token_Serializer::serialize()`: comments are dropped, each whitespace run +becomes one space, and blocks left open at the end of the style are closed. The +result is `name: value;` or `name: value !important;`, and CSS reads it as the same +declaration. + +This is the public way to write CSS from a parsed declaration. Token lists are +not a public unit for writing CSS: `WP_CSS_Token_Serializer` is internal. + ## Value Validation Mutation inputs accept CSS declaration values, not declaration-list text. 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 75a3df2edc43f..603f9f1cd2552 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 @@ -203,6 +203,34 @@ public function get_value_tokens(): ?array { return array_slice( $this->tokens, $start, $end - $start ); } + /** + * Serializes the current declaration. + * + * The property name is escaped as an identifier, and the value is written + * from its tokens: comments are dropped, each whitespace run becomes one + * space, and blocks left open at the end of the style are closed. The + * result is `name: value;` or `name: value !important;`, as in CSSOM, and + * CSS reads it as the same declaration. + * + * @since 7.2.0 + * + * @see WP_CSS_Token_Serializer::serialize() + * + * @return string|null Declaration text, or null when not on a declaration. + */ + public function serialize_declaration(): ?string { + $declaration = $this->get_current_declaration(); + if ( null === $declaration ) { + return null; + } + + return $this->format_declaration( + WP_CSS_Token_Serializer::escape_ident( $declaration['name'] ), + WP_CSS_Token_Serializer::serialize( $this->get_value_tokens() ), + $declaration['important'] + ); + } + /** * Sets the value of the current declaration. * @@ -228,7 +256,7 @@ public function set_value( string $value, ?bool $important = null ): bool { } $is_important = null === $important ? $declaration['important'] : $important; - $text = $this->serialize_declaration( $declaration['raw_name'], $this->trim_value( $value ), $is_important ); + $text = $this->format_declaration( $declaration['raw_name'], $this->trim_value( $value ), $is_important ); $parsed_value = $this->get_parsed_declaration_value( $text ); if ( null === $parsed_value ) { return false; @@ -424,7 +452,7 @@ public function append_declaration( string $property_name, string $value, bool $ $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, $this->trim_value( $value ), $important ); + $declaration_text = $this->format_declaration( $serialized_name, $this->trim_value( $value ), $important ); $parsed_value = $this->get_parsed_declaration_value( $declaration_text ); if ( null === $parsed_value ) { return false; @@ -1376,14 +1404,14 @@ private function merge_overlapping_lexical_updates( array $updates ): array { } /** - * Serializes a declaration. + * Formats a declaration as `name: value;` or `name: value !important;`. * - * @param string $property_name CSS property name. - * @param string $value CSS declaration value. + * @param string $property_name CSS property name text. + * @param string $value CSS declaration value text. * @param bool $important Whether to append !important. - * @return string Serialized declaration. + * @return string Declaration text. */ - private function serialize_declaration( string $property_name, string $value, bool $important ): string { + private function format_declaration( string $property_name, string $value, bool $important ): string { return $property_name . ': ' . $value . ( $important ? ' !important' : '' ) . ';'; } diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index e00f85b8e07c0..96274b54cc73f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -254,6 +254,146 @@ static function ( WP_CSS_Token $token ): array { $this->assertSame( $expected, $actual ); } + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * @covers ::remove_declaration + */ + public function test_serialize_declaration_returns_null_when_not_on_a_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertNull( $processor->serialize_declaration(), 'Expected null before the first declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->serialize_declaration(), 'Expected null after removing the declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertNull( $processor->serialize_declaration(), 'Expected null after the last declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * + * @dataProvider data_serialized_declarations + * + * @param string $style Style text. + * @param string $expected Expected text of the first declaration. + */ + public function test_serialize_declaration_writes_the_current_declaration( string $style, string $expected ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $expected, $processor->serialize_declaration() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_serialized_declarations(): array { + return array( + 'single ident' => array( 'color: red', 'color: red;' ), + 'important' => array( 'color : red ! important ;', 'color: red !important;' ), + 'important after a comment' => array( 'color: red /* x */ !important', 'color: red !important;' ), + 'lowercased name' => array( 'COLOR: Red', 'color: Red;' ), + 'escaped name' => array( 'c\\6f lor: red', 'color: red;' ), + 'custom property keeps its case' => array( '--Foo: a b', '--Foo: a b;' ), + 'custom property name escape' => array( '--a\\ b: 1', '--a\\20 b: 1;' ), + 'comments and whitespace runs' => array( "margin: 1px /* a */ \t 2px", 'margin: 1px 2px;' ), + 'comment between tokens' => array( 'margin: 1px/**/2px', 'margin: 1px/**/2px;' ), + 'empty value' => array( 'color:;', 'color: ;' ), + 'string' => array( "font-family: 'a\"b'", 'font-family: "a\\22 b";' ), + 'value ending in an escaped space' => array( '--x: a\\ ', '--x: a\\20 ;' ), + 'backslash before a newline' => array( "--x: a\\\n; width: 1px", "--x: a\\\n;" ), + 'block open at the end' => array( 'width: calc(1px + (2px', 'width: calc(1px + (2px));' ), + 'url open at the end' => array( 'background: url(a.png', 'background: url(a.png);' ), + 'semicolon inside an open block' => array( 'width: calc(1px; color: red', 'width: calc(1px; color: red);' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * + * @dataProvider data_value_tokens_source_slices + * + * @param string $style Style text. + */ + public function test_serialize_declaration_reads_back_as_the_same_declaration( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + $this->assertTrue( $processor->next_declaration() ); + + $reread = WP_HTML_Style_Attribute_Processor::create( $processor->serialize_declaration() ); + $this->assertTrue( $reread->next_declaration() ); + + $this->assertSame( $processor->get_property_name(), $reread->get_property_name() ); + $this->assertSame( $processor->is_important(), $reread->is_important() ); + $this->assertSame( + self::significant_tokens( $processor->get_value_tokens() ), + self::significant_tokens( $reread->get_value_tokens() ) + ); + $this->assertFalse( $reread->next_declaration(), 'Expected exactly one declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * @covers ::set_value + */ + public function test_serialize_declaration_follows_mutations() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( 'green /* c */', true ) ); + $this->assertSame( 'color: green !important;', $processor->serialize_declaration() ); + } + + /** + * Reads the fields that give a value its meaning, closing blocks left open. + * + * @param WP_CSS_Token[] $tokens Value tokens. + * @return array[] Type, flag, value and unit of each token except whitespace and comments. + */ + private static function significant_tokens( array $tokens ): array { + $closers = array( + WP_CSS_Token_Processor::TOKEN_FUNCTION => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_PAREN => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACE => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE, + ); + $open = array(); + $fields = array(); + + foreach ( $tokens as $token ) { + $type = $token->get_type(); + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type || WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) { + continue; + } + + if ( isset( $closers[ $type ] ) ) { + $open[] = $closers[ $type ]; + } elseif ( in_array( $type, $closers, true ) ) { + array_pop( $open ); + } + + $fields[] = array( $type, $token->get_flag(), $token->get_value(), $token->get_unit() ); + } + + foreach ( array_reverse( $open ) as $type ) { + $fields[] = array( $type, null, null, null ); + } + + return $fields; + } + /** * @covers ::set_value * @covers ::get_updated_style From 9907e3fc5b46c925914fab10b8ae8cf8c189dc9a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 12:44:12 +0400 Subject: [PATCH 19/21] Document the cost of remove_declaration() Every successful mutation parses the whole style text again, so removing declarations one at a time takes time that grows with the square of the style's length. Measured on this branch, removing every declaration of a style takes 12, 189 and 744 ms for 50, 200 and 400 declarations, while reading them all and joining serialize_declaration() takes 0.3, 1.2 and 2.4 ms. Say so on remove_declaration() and in the spec, and point to the alternative. --- docs/wp-html-style-attribute-processor-spec.md | 5 +++++ .../html-api/class-wp-html-style-attribute-processor.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 9e96a1f4eb374..04d085e3af2e4 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -111,6 +111,11 @@ 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. +Each successful mutation parses the whole style text again. Removing most of the +declarations of a long style one at a time therefore takes time that grows with +the square of its length. To keep some declarations, read them and join the +`serialize_declaration()` output of the ones to keep. + ## Property Names All public property-name arguments are decoded/plaintext CSS property names, not 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 603f9f1cd2552..91cef4bc3fdbe 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 @@ -387,6 +387,11 @@ public function set_important( bool $important ): bool { * Only the current declaration is removed. Other declarations with the same * property name remain in place. * + * Each call parses the whole style text again, so removing most of the + * declarations of a long style one at a time is slow. To keep some + * declarations, join the {@see WP_HTML_Style_Attribute_Processor::serialize_declaration()} + * output of the ones to keep instead. + * * @since 7.2.0 * * @return bool Whether the current declaration was removed. From 190cd2ed9dfee874f376aa8cce9a66629e516c79 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 13 Oct 2026 17:31:39 +0400 Subject: [PATCH 20/21] Write set_value() and append_declaration() from tokens set_value() and append_declaration() checked that a supplied value parses as one declaration value, then wrote the caller's bytes: comments, whitespace runs, raw control characters inside strings and any escape spelling reached the style. They now write the declaration as serialize_declaration() does: the name is escaped as an identifier and the value is written by WP_CSS_Token_Serializer from its tokens, with the whitespace and comments around it removed. set_value() writes the decoded name, so `c\6f lor` becomes `color`. trim_value() and the raw_name field have no other use and are removed. --- .../wp-html-style-attribute-processor-spec.md | 18 +++-- ...lass-wp-html-style-attribute-processor.php | 56 +++++++------ .../wpHtmlStyleAttributeProcessor.php | 80 ++++++++++++++++--- 3 files changed, 114 insertions(+), 40 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 04d085e3af2e4..84f8c9f1cbaff 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -129,9 +129,9 @@ exactly because casing is semantic. - 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. +Edits and appends write the property name as `get_property_name()` returns it, +escaped as an identifier: ordinary properties lowercase, custom properties with +their 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 @@ -262,11 +262,15 @@ 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. +`set_value()` and `append_declaration()` write a declaration as +`serialize_declaration()` does: `name: value;` or `name: value !important;`, with +the name escaped as an identifier and the value written from its tokens. Comments +and whitespace before and after a supplied value are removed, comments inside it +are dropped, and each whitespace run inside it becomes one space. Nothing is +copied from the supplied text. -When possible, `set_value()` replaces the current declaration's value and -priority portion rather than normalizing the whole declaration. Correctness wins -over whitespace preservation. +`set_value()` rewrites the whole current declaration; text around it is +unchanged. ## Invalid Fragments 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 91cef4bc3fdbe..ce796161df09d 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 @@ -53,7 +53,7 @@ class WP_HTML_Style_Attribute_Processor { /** * Parsed declarations. * - * @var array + * @var array */ private $declarations = array(); @@ -234,6 +234,10 @@ public function serialize_declaration(): ?string { /** * Sets the value of the current declaration. * + * The declaration is written as serialize_declaration() writes it: the + * name is escaped as an identifier and the value is written from its + * tokens. + * * @since 7.2.0 * * @param string $value CSS declaration value, without the property name. @@ -256,7 +260,7 @@ public function set_value( string $value, ?bool $important = null ): bool { } $is_important = null === $important ? $declaration['important'] : $important; - $text = $this->format_declaration( $declaration['raw_name'], $this->trim_value( $value ), $is_important ); + $text = $this->format_declaration( WP_CSS_Token_Serializer::escape_ident( $declaration['name'] ), $this->serialize_value( $value ), $is_important ); $parsed_value = $this->get_parsed_declaration_value( $text ); if ( null === $parsed_value ) { return false; @@ -426,7 +430,9 @@ public function remove_declaration(): bool { * Appends a declaration to the style attribute value. * * Appending does not remove or replace existing declarations with the same - * property name. + * property name. The declaration is written as serialize_declaration() + * writes it: the name is escaped as an identifier and the value is + * written from its tokens. * * @since 7.2.0 * @@ -456,14 +462,14 @@ public function append_declaration( string $property_name, string $value, bool $ } $separator = $this->get_append_separator( $insert_at ); - $serialized_name = $this->is_custom_property_name( $property_name ) ? $property_name : strtolower( $property_name ); - $declaration_text = $this->format_declaration( $serialized_name, $this->trim_value( $value ), $important ); + $name = $this->is_custom_property_name( $property_name ) ? $property_name : strtolower( $property_name ); + $declaration_text = $this->format_declaration( WP_CSS_Token_Serializer::escape_ident( $name ), $this->serialize_value( $value ), $important ); $parsed_value = $this->get_parsed_declaration_value( $declaration_text ); if ( null === $parsed_value ) { return false; } - if ( ! $this->is_parseable_append( $repair_at, $eof_repair, $separator, $declaration_text, $old_declaration_count, $serialized_name, $parsed_value, $important ) ) { + if ( ! $this->is_parseable_append( $repair_at, $eof_repair, $separator, $declaration_text, $old_declaration_count, $name, $parsed_value, $important ) ) { return false; } @@ -737,7 +743,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, value_tokens:array{int,int}, important:bool, important_start:int|null, important_end:int|null}|null + * @return array{name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,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 ); @@ -799,7 +805,6 @@ private function parse_declaration_segment( int $leading_start, int $start_index return array( 'name' => $name, - 'raw_name' => substr( $this->style, $name_token->get_start(), $name_token->get_length() ), 'leading_start' => $leading_start, 'start' => $name_token->get_start(), 'after' => $after, @@ -966,7 +971,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, value_tokens:array{int,int}, important:bool, important_start:int|null, important_end:int|null}|null + * @return array{name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,int}, important:bool, important_start:int|null, important_end:int|null}|null */ private function get_current_declaration(): ?array { if ( @@ -983,7 +988,7 @@ private function get_current_declaration(): ?array { /** * 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, value_tokens:array{int,int}, important:bool, important_start:int|null, important_end:int|null} $declaration Current declaration metadata. + * @param array{name:string, leading_start:int, start:int, after:int, trailing_end:int, value_start:int, value_end:int, value_tokens:array{int,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. @@ -1421,28 +1426,31 @@ private function format_declaration( string $property_name, string $value, bool } /** - * Removes the whitespace tokens before and after a declaration value. + * Writes a declaration value from its tokens. * - * Whitespace consumed by an escape is part of the value, and a `\` before - * a newline keeps one newline so that it does not escape the next byte. + * Whitespace and comments before and after the value are removed, and the + * rest is written as serialize_declaration() writes a value. * * @param string $value CSS declaration value. - * @return string Trimmed value. + * @return string Value text. */ - private function trim_value( string $value ): string { - $value = ltrim( $value, self::WHITESPACE ); + private function serialize_value( string $value ): string { $processor = WP_CSS_Token_Processor::create( $value ); - $end = 0; - $is_delim = false; - + $tokens = array(); while ( $processor->next_token() ) { - if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $processor->get_token_type() ) { - $end = $processor->get_token_start() + $processor->get_token_length(); - $is_delim = WP_CSS_Token_Processor::TOKEN_DELIM === $processor->get_token_type() && '\\' === $processor->get_token_value(); - } + $tokens[] = $processor->get_token(); + } + + $start = 0; + $end = count( $tokens ); + while ( $start < $end && $this->is_ignored_token( $tokens[ $start ] ) ) { + ++$start; + } + while ( $end > $start && $this->is_ignored_token( $tokens[ $end - 1 ] ) ) { + --$end; } - return substr( $value, 0, $end ) . ( $is_delim ? "\n" : '' ); + return WP_CSS_Token_Serializer::serialize( array_slice( $tokens, $start, $end - $start ) ); } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 96274b54cc73f..3051ee63da883 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -419,7 +419,7 @@ public function test_declaration_values_allow_leading_and_trailing_comments_as_t $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() ); + $this->assertSame( 'color: green; background: white;', $processor->get_updated_style() ); } /** @@ -454,16 +454,78 @@ public function test_trimming_a_value_keeps_its_final_escape_or_backslash_delimi */ public static function data_values_ending_in_escaped_whitespace_or_a_backslash_delimiter() { return array( - 'escaped space' => array( 'a\\ ', 'a\\ ' ), - 'escaped space, then whitespace' => array( "a\\ \t", 'a\\ ' ), - 'escaped tab' => array( "a\\\t", "a\\\t" ), + 'escaped space' => array( 'a\\ ', 'a\\20 ' ), + 'escaped space, then whitespace' => array( "a\\ \t", 'a\\20 ' ), + 'escaped tab' => array( "a\\\t", 'a\\9 ' ), 'backslash before a newline' => array( "a\\\n", "a\\\n" ), 'backslash, newline, then spaces' => array( "a\\\n ", "a\\\n" ), - 'escaped backslash' => array( 'a\\\\ ', 'a\\\\' ), - 'escaped backslash, then a delim' => array( "a\\\\\\\n", "a\\\\\\\n" ), + 'escaped backslash' => array( 'a\\\\ ', 'a\\5c ' ), + 'escaped backslash, then a delim' => array( "a\\\\\\\n", "a\\5c \\\n" ), ); } + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_values_written_from_tokens + * + * @param string $value Declaration value. + * @param string $written Expected value text in the updated style. + */ + public function test_set_value_and_append_declaration_write_the_value_from_its_tokens( string $value, string $written ) { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: 1; width: 1px' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( $value ), 'Expected set_value() to succeed.' ); + $this->assertSame( "--x: {$written}; width: 1px", $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: 1px' ); + + $this->assertTrue( $processor->append_declaration( '--x', $value ), 'Expected append_declaration() to succeed.' ); + $this->assertSame( "width: 1px; --x: {$written};", $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_values_written_from_tokens() { + return array( + 'comments and whitespace runs' => array( " \t/* a */ a \t/* b */ b /* c */ ", 'a b' ), + 'comment between tokens' => array( 'a/**/b', 'a/**/b' ), + 'escaped ident' => array( '\\66 oo', 'foo' ), + 'single-quoted string' => array( "'a'", '"a"' ), + 'control character in a string' => array( "\"a\x01b\"", '"a\\1 b"' ), + 'url with spaces and ampersand' => array( 'url( a&b )', 'url(a\\26 b)' ), + 'non-ident code point in ident' => array( "a\u{A0}b", 'a\\a0 b' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_set_value_and_append_declaration_write_the_name_as_an_identifier() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'C\\6f LOR: red' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( 'blue' ) ); + $this->assertSame( 'color: blue;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertTrue( $processor->append_declaration( "--a\u{A0}b", '1' ) ); + $this->assertSame( '--a\\a0 b: 1;', $processor->get_updated_style() ); + } + /** * @covers ::set_value * @covers ::is_important @@ -956,18 +1018,18 @@ public function test_custom_property_values_may_mix_blocks_with_other_tokens() { * @covers ::set_value * @covers ::get_updated_style */ - public function test_escaped_property_names_match_decoded_names_and_preserve_raw_spelling() { + public function test_escaped_property_names_match_decoded_names_and_are_written_as_identifiers() { $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() ); $this->assertTrue( $processor->set_value( 'green' ) ); - $this->assertSame( 'c\\6f lor: green; --t\\6f ne: cool; --Tone: warm;', $processor->get_updated_style() ); + $this->assertSame( 'color: 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->assertTrue( $processor->set_value( 'cold' ) ); - $this->assertSame( 'c\\6f lor: green; --t\\6f ne: cold; --Tone: warm;', $processor->get_updated_style() ); + $this->assertSame( 'color: green; --tone: cold; --Tone: warm;', $processor->get_updated_style() ); $this->assertFalse( $processor->next_declaration( '--tone' ) ); } From 755bff5e070ed2eb6f0786cd99031c98a6c3f068 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 13 Oct 2026 17:32:51 +0400 Subject: [PATCH 21/21] Close strings and comments left open before an append CSS closes a string or a comment that the end of the input cuts off, and so does WP_CSS_Token_Serializer::serialize(), but get_eof_repair() closed only blocks and urls: append_declaration() and set_important() returned false after `font-family: "a` or `color: red /* x`. The repair now writes the string's own quote or `*/` before the block closers. A `\` at the end of the style still makes the append fail, because no appended text can end the escape it starts. --- .../wp-html-style-attribute-processor-spec.md | 6 +- ...lass-wp-html-style-attribute-processor.php | 19 ++++-- .../wpHtmlStyleAttributeProcessor.php | 58 +++++++++++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docs/wp-html-style-attribute-processor-spec.md b/docs/wp-html-style-attribute-processor-spec.md index 84f8c9f1cbaff..d0060646e8262 100644 --- a/docs/wp-html-style-attribute-processor-spec.md +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -306,8 +306,10 @@ semantics and structure. 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. +string, comment, function or block at EOF, appending must materialize the +missing closing delimiters before inserting the new declaration. A string is +closed with the quote it starts with and a comment with `*/`. A `\` at EOF +cannot be closed this way, so append returns `false`. Example: 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 ce796161df09d..b3108d5ae8238 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 @@ -1242,14 +1242,16 @@ private function get_last_top_level_non_ignored_token_before( int $insert_at ): /** * 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. + * CSS closes unterminated strings, comments, 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(); + $stack = array(); + $token_closer = ''; foreach ( $this->tokens as $token ) { if ( $token->get_start() >= $insert_at ) { @@ -1267,7 +1269,14 @@ private function get_eof_repair( int $insert_at ): ?string { case WP_CSS_Token_Processor::TOKEN_COMMENT: if ( ! $token->is_terminated() ) { - return null; + $token_closer = '*/'; + } + break; + + case WP_CSS_Token_Processor::TOKEN_STRING: + if ( ! $token->is_terminated() ) { + // The string ends with the quote it starts with. + $token_closer = $this->style[ $token->get_start() ]; } break; @@ -1310,7 +1319,7 @@ private function get_eof_repair( int $insert_at ): ?string { } } - return implode( '', array_reverse( $stack ) ); + return $token_closer . implode( '', array_reverse( $stack ) ); } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php index 3051ee63da883..ec67adccd5422 100644 --- a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -931,11 +931,60 @@ public function test_append_declaration_repairs_unclosed_eof_component_values() $this->assertSame( 'background: url(foo\\)); border: 0;', $processor->get_updated_style() ); } + /** + * @ticket 65738 + * + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_styles_ending_inside_a_string_or_comment + * + * @param string $style Style text. + * @param string $expected Updated style text. + */ + public function test_append_declaration_closes_strings_and_comments_left_open( string $style, string $expected ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( $expected, $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_styles_ending_inside_a_string_or_comment(): array { + return array( + 'comment inside an open function' => array( 'color: var(--x;/*keep', 'color: var(--x;/*keep*/); background: white;' ), + 'comment after a declaration' => array( 'color: red /* keep', 'color: red /* keep*/; background: white;' ), + 'comment ending in a slash' => array( 'color: red /*/', 'color: red /*/*/; background: white;' ), + 'comment ending in a backslash' => array( 'color: red /* \\', 'color: red /* \\*/; background: white;' ), + 'double-quoted string' => array( 'font-family: "a', 'font-family: "a"; background: white;' ), + 'single-quoted string' => array( "font-family: 'a", "font-family: 'a'; background: white;" ), + 'string inside an open function' => array( 'width: var(--x, "a', 'width: var(--x, "a"); background: white;' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::set_important + * @covers ::get_updated_style + */ + public function test_set_important_closes_a_string_left_open() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'font-family: "a' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertSame( 'font-family: "a" !important', $processor->get_updated_style() ); + } + /** * @covers ::append_declaration * @covers ::get_updated_style * - * @dataProvider data_styles_ending_inside_a_comment + * @dataProvider data_styles_ending_in_a_backslash * * @param string $style Style text. */ @@ -951,11 +1000,10 @@ public function test_append_declaration_rejects_eof_repairs_that_cannot_be_preci * * @return array */ - public static function data_styles_ending_inside_a_comment(): array { + public static function data_styles_ending_in_a_backslash(): array { return array( - 'comment inside an open function' => array( 'color: var(--x;/*keep' ), - 'comment after a declaration' => array( 'color: red /* keep' ), - 'comment ending in a slash' => array( 'color: red /*/' ), + 'backslash after an ident' => array( 'color: x\\' ), + 'backslash inside a string' => array( 'content: "a\\' ), ); }