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..d0060646e8262
--- /dev/null
+++ b/docs/wp-html-style-attribute-processor-spec.md
@@ -0,0 +1,356 @@
+# 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( $decoded_css_text )
+```
+
+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.
+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 and token index 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( $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 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;
+public function append_declaration( string $property_name, string $value, bool $important = false ): bool;
+public function get_updated_style(): string;
+```
+
+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.
+
+## 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()` 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
+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.
+
+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
+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 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
+same validity rule with the required `--` prefix.
+
+## Value Tokens
+
+`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
+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 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
+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.
+
+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()` 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.
+
+`set_value()` rewrites the whole current declaration; text around it is
+unchanged.
+
+## 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. 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
+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
+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:
+
+```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;
+- 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;
+- 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.
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..b3108d5ae8238
--- /dev/null
+++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php
@@ -0,0 +1,1704 @@
+
+ */
+ 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.
+ *
+ * @var int
+ */
+ 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.
+ *
+ * @var array
+ */
+ private $lexical_updates = array();
+
+ /**
+ * Sequence counter used to keep lexical updates in queue order.
+ *
+ * @var int
+ */
+ private $lexical_update_order = 0;
+
+ /**
+ * Constructor.
+ *
+ * Do not instantiate directly. Use
+ * {@see WP_HTML_Style_Attribute_Processor::create()} instead.
+ *
+ * @since 7.2.0
+ *
+ * @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.
+ *
+ * @since 7.2.0
+ *
+ * @param string $decoded_css_text Decoded CSS text value from a style attribute.
+ * @return static Created processor.
+ */
+ 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' );
+ }
+
+ return new static( $decoded_css_text );
+ }
+
+ /**
+ * 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.
+ *
+ * @since 7.2.0
+ *
+ * @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 ||
+ $this->matches_property_name( $this->declarations[ $i ]['name'], $property_name )
+ ) {
+ $this->current_declaration = $i;
+ $this->current_declaration_removed = false;
+ return true;
+ }
+ }
+
+ $this->current_declaration = count( $this->declarations );
+ $this->current_declaration_removed = false;
+ return false;
+ }
+
+ /**
+ * Gets the current declaration's property name.
+ *
+ * @since 7.2.0
+ *
+ * @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'];
+ }
+
+ /**
+ * Indicates whether the current declaration has an !important priority.
+ *
+ * @since 7.2.0
+ *
+ * @return bool|null Whether the current declaration has an !important priority,
+ * or null when not on a declaration.
+ */
+ public function is_important(): ?bool {
+ $declaration = $this->get_current_declaration();
+ 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:;`.
+ *
+ * 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 WP_CSS_Token[]|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 );
+ }
+
+ /**
+ * 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.
+ *
+ * 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.
+ * @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 ) {
+ 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->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;
+ }
+
+ 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'],
+ $declaration['after'] - $declaration['start'],
+ $text,
+ $this->current_declaration
+ );
+
+ $this->apply_lexical_updates( $this->current_declaration );
+
+ return true;
+ }
+
+ /**
+ * Sets the !important priority of the current declaration.
+ *
+ * @since 7.2.0
+ *
+ * @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.
+ *
+ * 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.
+ */
+ public function remove_declaration(): bool {
+ $declaration = $this->get_current_declaration();
+ if ( null === $declaration ) {
+ return false;
+ }
+
+ $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 ) ) ? ' ' : '';
+
+ 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,
+ $replacement,
+ $this->current_declaration
+ );
+
+ $this->apply_lexical_updates( $this->current_declaration - 1, true );
+
+ return true;
+ }
+
+ /**
+ * Appends a declaration to the style attribute value.
+ *
+ * Appending does not remove or replace existing declarations with the same
+ * 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
+ *
+ * @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 (
+ '' === trim( $value, self::WHITESPACE ) ||
+ ! $this->is_valid_property_name( $property_name ) ||
+ ! $this->is_valid_declaration_value( $value )
+ ) {
+ return false;
+ }
+
+ $this->ensure_parsed();
+
+ $old_declaration_count = count( $this->declarations );
+ $trimmed_style = rtrim( $this->style, self::WHITESPACE );
+ $insert_at = strlen( $trimmed_style );
+ $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 );
+ $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, $name, $parsed_value, $important ) ) {
+ return false;
+ }
+
+ $this->queue_lexical_update(
+ $repair_at,
+ 0,
+ $eof_repair . $separator . $declaration_text,
+ 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;
+ }
+
+ /**
+ * Returns the updated decoded CSS text value for the style attribute.
+ *
+ * @since 7.2.0
+ *
+ * @return string Updated decoded CSS text value for the style attribute.
+ */
+ 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 {
+ $this->tokens = array();
+ $this->declarations = array();
+
+ $processor = WP_CSS_Token_Processor::create( $this->style );
+ if ( null === $processor ) {
+ $this->parsed = true;
+ return;
+ }
+
+ while ( $processor->next_token() ) {
+ $this->tokens[] = $processor->get_token();
+ }
+
+ $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();
+ }
+
+ /**
+ * 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 ]->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 );
+ }
+
+ if ( $index >= $count ) {
+ break;
+ }
+
+ 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;
+ }
+
+ 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->get_type() ) {
+ return array( $index + 1, self::get_token_end( $token ) );
+ }
+
+ 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->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->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->get_type() ) {
+ list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] );
+ 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->get_type() ) {
+ return array( $index + 1, self::get_token_end( $token ) );
+ }
+
+ 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->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->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->get_type() ) {
+ list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] );
+ 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->get_type() ) {
+ return array( $index, self::get_token_end( $token ), true );
+ }
+
+ 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->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->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->get_type() ) {
+ list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ $after = empty( $this->tokens ) ? strlen( $this->style ) : self::get_token_end( $this->tokens[ $count - 1 ] );
+ 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, 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 );
+ 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->get_value();
+ if ( null === $name ) {
+ return null;
+ }
+
+ $name = $this->is_custom_property_name( $name ) ? $name : strtolower( $name );
+
+ ++$index;
+ $index = $this->skip_ignored_tokens( $index, $end_index );
+
+ if ( $index >= $end_index || WP_CSS_Token_Processor::TOKEN_COLON !== $this->tokens[ $index ]->get_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;
+ $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;
+ $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 ]->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 ]->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 = self::get_token_end( $colon_token );
+ $value_end = self::get_token_end( $colon_token );
+ } else {
+ $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 ) ) ) {
+ return null;
+ }
+
+ return array(
+ 'name' => $name,
+ 'leading_start' => $leading_start,
+ 'start' => $name_token->get_start(),
+ 'after' => $after,
+ '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,
+ );
+ }
+
+ /**
+ * 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->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->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->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->get_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 ] ) ? self::get_token_end( $this->tokens[ $index - 1 ] ) : 0;
+ $count = count( $this->tokens );
+
+ while ( $index < $count && $this->is_ignored_token( $this->tokens[ $index ] ) ) {
+ $end = self::get_token_end( $this->tokens[ $index ] );
+ ++$index;
+ }
+
+ return $end;
+ }
+
+ /**
+ * Checks whether a token is ignored by declaration-list grammar.
+ *
+ * @param WP_CSS_Token $token Token.
+ * @return bool Whether the token is ignored.
+ */
+ private function is_ignored_token( WP_CSS_Token $token ): bool {
+ return (
+ 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.
+ *
+ * @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.
+ *
+ * @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 (
+ $this->current_declaration_removed ||
+ $this->current_declaration < 0 ||
+ ! isset( $this->declarations[ $this->current_declaration ] )
+ ) {
+ return null;
+ }
+
+ return $this->declarations[ $this->current_declaration ];
+ }
+
+ /**
+ * Checks whether a replacement preserves the current declaration's structure.
+ *
+ * @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.
+ * @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.
+ *
+ * @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->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();
+
+ 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.
+ *
+ * @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;
+ }
+
+ /**
+ * 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->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 WP_CSS_Token|null Token, or null when there is none.
+ */
+ 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;
+
+ while ( $index < $count ) {
+ $token = $this->tokens[ $index ];
+
+ if ( $token->get_start() >= $insert_at ) {
+ break;
+ }
+
+ if ( $this->is_ignored_token( $token ) ) {
+ ++$index;
+ continue;
+ }
+
+ $last_token = $token;
+
+ 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->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->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->get_type() ) {
+ list( $index ) = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ return $last_token;
+ }
+
+ /**
+ * Gets required closing delimiters before appending at EOF.
+ *
+ * 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();
+ $token_closer = '';
+
+ foreach ( $this->tokens as $token ) {
+ if ( $token->get_start() >= $insert_at ) {
+ break;
+ }
+
+ if ( self::get_token_end( $token ) > $insert_at ) {
+ return null;
+ }
+
+ 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 ( ! $token->is_terminated() ) {
+ $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;
+
+ 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 ( ! $token->is_terminated() ) {
+ $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 $token_closer . implode( '', array_reverse( $stack ) );
+ }
+
+ /**
+ * 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 $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 $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 ) . $eof_repair . $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;
+ }
+
+ foreach ( $candidate->declarations as $declaration ) {
+ if ( $expected_start === $declaration['start'] && $expected_after === $declaration['after'] ) {
+ $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']
+ );
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * Formats a declaration as `name: value;` or `name: value !important;`.
+ *
+ * @param string $property_name CSS property name text.
+ * @param string $value CSS declaration value text.
+ * @param bool $important Whether to append !important.
+ * @return string Declaration text.
+ */
+ private function format_declaration( string $property_name, string $value, bool $important ): string {
+ return $property_name . ': ' . $value . ( $important ? ' !important' : '' ) . ';';
+ }
+
+ /**
+ * Writes a declaration value from its tokens.
+ *
+ * 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 Value text.
+ */
+ private function serialize_value( string $value ): string {
+ $processor = WP_CSS_Token_Processor::create( $value );
+ $tokens = array();
+ while ( $processor->next_token() ) {
+ $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 WP_CSS_Token_Serializer::serialize( array_slice( $tokens, $start, $end - $start ) );
+ }
+
+ /**
+ * 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 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->get_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->get_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.
+ *
+ * @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 {
+ 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;
+ }
+
+ 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 {
+ 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 ) {
+ 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;
+ }
+
+ if ( empty( $top_level_tokens ) ) {
+ return false;
+ }
+
+ for ( $i = 1; $i < count( $top_level_tokens ); $i++ ) {
+ $token = $top_level_tokens[ $i ];
+ $before_token = $top_level_tokens[ $i - 1 ];
+
+ 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/src/wp-settings.php b/src/wp-settings.php
index 609c05b85e125..0c73e4f6d41f6 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -292,6 +292,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..ec67adccd5422
--- /dev/null
+++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php
@@ -0,0 +1,1395 @@
+assertSame( 'color: red', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::create
+ *
+ * @dataProvider data_non_string_style_attribute_values
+ *
+ * @param mixed $style_attribute_value Non-string style attribute value.
+ */
+ 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 );
+ }
+
+ /**
+ * @coversNothing
+ */
+ public function test_constructor_is_not_public() {
+ $reflection = new ReflectionClass( WP_HTML_Style_Attribute_Processor::class );
+ $constructor = $reflection->getConstructor();
+
+ $this->assertNotNull( $constructor );
+ $this->assertFalse( $constructor->isPublic() );
+ }
+
+ /**
+ * @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' ) );
+ $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_value_source' ) );
+ }
+
+ /**
+ * @covers ::next_declaration
+ * @covers ::get_property_name
+ * @covers ::is_important
+ */
+ public function test_next_declaration_reads_properties_in_order_and_preserves_duplicates() {
+ $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->assertFalse( $processor->is_important() );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'color', $processor->get_property_name() );
+ $this->assertTrue( $processor->is_important() );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+
+ $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 = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white; COLOR: blue;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertSame( 'color', $processor->get_property_name() );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertSame( 'color', $processor->get_property_name() );
+
+ $this->assertFalse( $processor->next_declaration( 'color' ) );
+ }
+
+ /**
+ * @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( '--tone', $processor->get_property_name() );
+
+ $this->assertFalse( $processor->next_declaration( '--tone' ) );
+ }
+
+ /**
+ * @covers ::next_declaration
+ */
+ public function test_next_declaration_accepts_named_property_name_argument() {
+ $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'background' ) );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ }
+
+ /**
+ * @covers ::is_important
+ *
+ * @dataProvider data_important_priority_syntax
+ *
+ * @param string $style Style attribute value.
+ * @param bool $important Expected importance.
+ */
+ 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( $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() );
+ $tokens = $processor->get_value_tokens();
+
+ $this->assertContainsOnlyInstancesOf( WP_CSS_Token::class, $tokens );
+ $this->assertSame( $expected_tokens, array_map( array( self::class, 'token_fields' ), $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]->get_type() );
+ $this->assertSame( 'a"b', $tokens[0]->get_value() );
+ $this->assertSame( '"a\\"b"', substr( $style, $tokens[0]->get_start(), $tokens[0]->get_length() ) );
+ }
+
+ /**
+ * @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 ) ),
+ array_map( array( self::class, 'token_fields' ), $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 ) ),
+ array_map( array( self::class, 'token_fields' ), $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->get_start(), $last->get_start() + $last->get_length() - $first->get_start() );
+
+ $expected = array();
+ $tokenizer = WP_CSS_Token_Processor::create( $slice );
+ while ( $tokenizer->next_token() ) {
+ $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 ( WP_CSS_Token $token ): array {
+ return array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->is_terminated() );
+ },
+ $tokens
+ );
+
+ $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
+ */
+ public function test_set_value_updates_current_declaration_without_collapsing_duplicates() {
+ $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 ) );
+
+ $this->assertSame( 'color: green; color: color(display-p3 1 0 0);', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::set_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: green; background: white;', $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\\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\\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
+ */
+ 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' ) );
+ $this->assertTrue( $processor->is_important() );
+
+ $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 ::set_value
+ * @covers ::get_updated_style
+ */
+ 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->set_important( true ) );
+ $this->assertTrue( $processor->is_important() );
+
+ $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_updated_style
+ */
+ 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->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 = WP_HTML_Style_Attribute_Processor::create( '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 ::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;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+
+ $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() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ }
+
+ /**
+ * @covers ::remove_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_remove_declaration_preserves_surrounding_comments() {
+ $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 = 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 = 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 = WP_HTML_Style_Attribute_Processor::create( '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 = WP_HTML_Style_Attribute_Processor::create( '' );
+
+ $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 ::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
+ */
+ public function test_append_declaration_treats_comments_as_trivia_for_separators() {
+ $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 = 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 = 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() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_lowercases_ordinary_properties_and_preserves_custom_properties() {
+ $processor = WP_HTML_Style_Attribute_Processor::create( '' );
+
+ $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 = 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() );
+ }
+
+ /**
+ * @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 = 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->is_important() );
+ $this->assertFalse( $processor->set_value( 'green' ) );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ $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 = WP_HTML_Style_Attribute_Processor::create( '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 ::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() );
+ }
+
+ /**
+ * @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_in_a_backslash
+ *
+ * @param string $style Style text.
+ */
+ 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_in_a_backslash(): array {
+ return array(
+ 'backslash after an ident' => array( 'color: x\\' ),
+ 'backslash inside a string' => array( 'content: "a\\' ),
+ );
+ }
+
+ /**
+ * @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 = WP_HTML_Style_Attribute_Processor::create( $style );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ $this->assertFalse( $processor->next_declaration() );
+ $this->assertSame( $style, $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::next_declaration
+ */
+ public function test_stray_right_brace_consumes_bad_declaration_until_semicolon() {
+ $processor = WP_HTML_Style_Attribute_Processor::create( '} color: red; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ $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
+ * @covers ::get_updated_style
+ */
+ 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( '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( 'color: green; --tone: cold; --Tone: warm;', $processor->get_updated_style() );
+
+ $this->assertFalse( $processor->next_declaration( '--tone' ) );
+ }
+
+ /**
+ * @covers ::is_important
+ */
+ public function test_important_inside_unclosed_function_is_not_declaration_priority() {
+ $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x, red !important' );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertFalse( $processor->is_important() );
+ }
+
+ /**
+ * @covers ::set_value
+ * @covers ::append_declaration
+ * @covers ::get_updated_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( '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_append_declaration_rejects_empty_values() {
+ $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' );
+
+ $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 ::get_updated_style
+ */
+ 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( '/* 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
+ *
+ * @param string $value Malformed CSS declaration value.
+ */
+ public function test_declaration_values_reject_malformed_eof_tokens( string $value ) {
+ $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_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' ) );
+
+ $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() ) );
+
+ $this->assertSame(
+ 'Text
',
+ $tags->get_updated_html()
+ );
+ }
+
+ /**
+ * 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.
+ * @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(
+ 'type' => $type,
+ 'value' => $value,
+ 'unit' => $unit,
+ 'start' => $start,
+ 'length' => $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.
+ *
+ * @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, '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 ),
+ 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, '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, 'px' ),
+ ),
+ ),
+ '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 ),
+ ),
+ ),
+ '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() ),
+ );
+ }
+
+ /**
+ * 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.
+ *
+ * @return array
+ */
+ public static function data_non_string_style_attribute_values(): array {
+ return array(
+ 'missing style attribute' => array( null ),
+ 'boolean style attribute' => array( true ),
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_important_priority_syntax(): array {
+ return array(
+ '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 ),
+ '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;' ),
+ );
+ }
+
+ /**
+ * 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\\' ),
+ );
+ }
+}