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..9d24f841676c2
--- /dev/null
+++ b/docs/wp-html-style-attribute-processor-spec.md
@@ -0,0 +1,346 @@
+# 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 the public constructor:
+
+```php
+new WP_HTML_Style_Attribute_Processor( $decoded_css_text )
+```
+
+The constructor accepts a string, `true` or `null`, the values
+`WP_HTML_Tag_Processor::get_attribute( 'style' )` returns. `true` (an attribute
+with no value) and `null` (a missing attribute) give an empty style. Any other
+type throws a `TypeError`. The implementation must remain compatible with
+WordPress' supported PHP versions, so the runtime signature uses PHPDoc and
+explicit validation instead of a union type.
+
+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 function __construct( $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
+construct 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.
+
+`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.
+
+A successful mutation updates the style text and the declaration list before
+it returns, so later getters and `next_declaration()` see the updated list.
+
+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 the constructor. 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;
+- 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:
+
+- construction, including `true`, `null` and other non-string input;
+- 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
+ rejection;
+- `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/css-api/class-wp-css-builder.php b/src/wp-includes/css-api/class-wp-css-builder.php
new file mode 100644
index 0000000000000..d778780c91462
--- /dev/null
+++ b/src/wp-includes/css-api/class-wp-css-builder.php
@@ -0,0 +1,110 @@
+ & , ;
+ * { }`, which may be problematic in HTML or for rudimentary CSS
+ * processors. CSS cannot represent NULL, so it reads back as U+FFFD.
+ * Invalid UTF-8 reads back as U+FFFD.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * echo 'content: ' . WP_CSS_Builder::string( 'Say "hi"' );
+ * // Outputs: content: "Say \22 hi\22 "
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @param string $value Value the string should read back as.
+ * @return string Quoted CSS string.
+ */
+ public static function string( string $value ): string {
+ return WP_CSS_Token_Serializer::escape_string( $value );
+ }
+
+ /**
+ * Writes a URL as a CSS `url()` function holding a quoted string.
+ *
+ * The URL is written as string() writes it. It is not validated or
+ * sanitized; pass it through esc_url() or sanitize_url() first when it
+ * comes from input.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * echo WP_CSS_Builder::url( 'https://example.com/a(1).png' );
+ * // Outputs: url("https://example.com/a(1).png")
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @link https://www.w3.org/TR/css-values-4/#urls
+ *
+ * @param string $url URL the function should read back as.
+ * @return string CSS `url()` function.
+ */
+ public static function url( string $url ): string {
+ return 'url(' . self::string( $url ) . ')';
+ }
+
+ /**
+ * Writes a value as a CSS identifier.
+ *
+ * Code points that cannot appear in an identifier at their position are
+ * hex-escaped, as are the characters string() escapes. Use it for a
+ * property name, a custom property name in `var()`, or a keyword or
+ * family name written without quotes.
+ *
+ * An empty value returns an empty string, which is not an identifier.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * echo 'font-family: ' . WP_CSS_Builder::ident( '1st Font' );
+ * // Outputs: font-family: \31 st\20 Font
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
+ *
+ * @param string $value Value the identifier should read back as.
+ * @return string CSS identifier, or an empty string for an empty value.
+ */
+ public static function ident( string $value ): string {
+ return WP_CSS_Token_Serializer::escape_ident( $value );
+ }
+}
diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php
new file mode 100644
index 0000000000000..e9b0e27e4bd7e
--- /dev/null
+++ b/src/wp-includes/css-api/class-wp-css-token-processor.php
@@ -0,0 +1,1791 @@
+ Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF)
+ * > code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE
+ * > FEED (LF) in input by a single U+000A LINE FEED (LF) code point.
+ * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT
+ * > CHARACTER (�).
+ *
+ * This processor delays normalization as much as possible. That keeps the raw byte
+ * positions intact for accurate rewrites; WP_CSS_Token::get_value() normalizes and
+ * decodes a token's value when it is asked for.
+ * For example, `url(ab)` is a URL token whose value is `a�b`, where ``
+ * means one zero byte in the source. That byte stays in the raw token, so later
+ * edits still use byte offsets from the original input.
+ *
+ * ### No EOF token
+ *
+ * The EOF token is a CSS parsing concept, not CSS tokenization concept. Therefore,
+ * this processor does not produce it.
+ *
+ * ### UTF-8 handling
+ *
+ * Only UTF-8 strings are supported. Invalid sequences are replaced with U+FFFD (�)
+ * by the maximal subpart rule of the Unicode Standard, section 3.9.6, U+FFFD
+ * Substitution of Maximal Subparts.
+ *
+ * ## Usage
+ *
+ * Basic iteration:
+ *
+ * ```php interactive
+ * $processor = new WP_CSS_Token_Processor( 'width: 10px;' );
+ * while ( $processor->next_token() ) {
+ * echo $processor->get_token_type() . "\n";
+ * }
+ * // Outputs:
+ * // ident-token
+ * // colon-token
+ * // whitespace-token
+ * // dimension-token
+ * // semicolon-token
+ * ```
+ *
+ * Gathering diagnostics with byte offsets:
+ *
+ * ```php interactive
+ * $css = "color: red;\ncolor: \"re\nd\";";
+ * $processor = new WP_CSS_Token_Processor( $css );
+ * while ( $processor->next_token() ) {
+ * if ( WP_CSS_Token_Processor::TOKEN_BAD_STRING === $processor->get_token_type() ) {
+ * echo "Bad string at byte {$processor->get_token_start()}, {$processor->get_token_length()} bytes long.\n";
+ * }
+ * }
+ * // Outputs: "Bad string at byte 19, 3 bytes long."
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#tokenization
+ * @link https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/#G66453
+ */
+class WP_CSS_Token_Processor {
+ /**
+ * Token type constants matching the CSS Syntax Level 3 specification.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#tokenization
+ */
+ public const TOKEN_WHITESPACE = 'whitespace-token';
+
+ /**
+ * Comment tokens, which open with `/` `*` and close with `*` `/`.
+ *
+ * The specification's tokenizer produces no comment tokens. This processor
+ * does, so callers can find comments by their byte offsets. CSS Syntax
+ * Level 3 §9 allows this:
+ *
+ * > Implementations may preserve the contents of comments and their location in the token stream.
+ * > If they do, this preserved information must have no effect on the parsing step.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#serialization
+ */
+ public const TOKEN_COMMENT = 'comment';
+
+ /**
+ * String tokens, such as `"hello"` and `'world'`.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_BAD_STRING
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-string-token
+ */
+ public const TOKEN_STRING = 'string-token';
+
+ /**
+ * BAD-STRING tokens occur when a string contains an unescaped newline.
+ *
+ * These are valid strings; the last contains an escaped newline:
+ *
+ * "hello" 'world' "line1\Aline2"
+ *
+ * This string produces a bad-string token, because a literal newline ends it:
+ *
+ * "hello
+ * world"
+ *
+ * The processor stops at the newline and produces a bad-string token for error recovery.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_STRING
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token
+ */
+ public const TOKEN_BAD_STRING = 'bad-string-token';
+
+ /**
+ * Hash tokens, such as `#fff` and `#header`.
+ *
+ * A hash token has an "id" or "unrestricted" type flag.
+ *
+ * @see WP_CSS_Token_Processor::HASH_TOKEN_ID
+ * @see WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-hash-token
+ */
+ public const TOKEN_HASH = 'hash-token';
+
+ /**
+ * Type flag of a hash token whose name would start an identifier, such as `#header`.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_HASH
+ */
+ public const HASH_TOKEN_ID = 'id';
+
+ /**
+ * Type flag of a hash token whose name would not start an identifier, such as `#123`.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_HASH
+ */
+ public const HASH_TOKEN_UNRESTRICTED = 'unrestricted';
+
+ public const TOKEN_DELIM = 'delim-token';
+ public const TOKEN_NUMBER = 'number-token';
+ public const TOKEN_PERCENTAGE = 'percentage-token';
+ public const TOKEN_DIMENSION = 'dimension-token';
+ public const TOKEN_AT_KEYWORD = 'at-keyword-token';
+ public const TOKEN_COLON = 'colon-token';
+ public const TOKEN_SEMICOLON = 'semicolon-token';
+ public const TOKEN_COMMA = 'comma-token';
+ public const TOKEN_LEFT_PAREN = '(-token';
+ public const TOKEN_RIGHT_PAREN = ')-token';
+ public const TOKEN_LEFT_BRACKET = '[-token';
+ public const TOKEN_RIGHT_BRACKET = ']-token';
+ public const TOKEN_LEFT_BRACE = '{-token';
+ public const TOKEN_RIGHT_BRACE = '}-token';
+ public const TOKEN_FUNCTION = 'function-token';
+
+ /**
+ * URL tokens represent unquoted URLs in url() notation.
+ *
+ * For example, `url(image.jpg)` is a URL token.
+ *
+ * Quoted URLs like `url( "https://example.com" )` are handled as a function
+ * token, _not_ a URL token.
+ *
+ * Bad URL tokens are created when invalid characters are encountered in
+ * a URL token.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_BAD_URL
+ * @see WP_CSS_Token_Processor::TOKEN_FUNCTION
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-url-token
+ */
+ public const TOKEN_URL = 'url-token';
+
+ /**
+ * BAD-URL tokens occur when a URL contains invalid characters.
+ *
+ * Invalid characters include quotes (`"`), apostrophes (`'`) and opening
+ * parentheses (`(`), as in `url(image(.jpg)` or `url(image".jpg)`.
+ *
+ * When detected, the processor consumes everything up to `)` or EOF.
+ * This prevents the bad URL from breaking subsequent tokens.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_URL
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token
+ */
+ public const TOKEN_BAD_URL = 'bad-url-token';
+
+ /**
+ * Identifier tokens, such as `color`, `margin-top`, `red`,
+ * `inherit`, `--my-var`, `\x-escaped`, `über` (Unicode), etc.
+ *
+ * There are restrictions on the codepoints that start or are contained in
+ * an identifier, and identifiers may contain escape sequences.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-ident-token
+ */
+ public const TOKEN_IDENT = 'ident-token';
+
+ /**
+ * CDC (Comment Delimiter Close) token: `-->`
+ *
+ * Legacy token from when CSS was embedded in HTML `
+ *
+ * Modern CSS no longer needs these, but they're preserved for compatibility.
+ * In stylesheets, they're typically treated like whitespace.
+ *
+ * @see WP_CSS_Token_Processor::TOKEN_CDO
+ * @link https://www.w3.org/TR/css-syntax-3/#typedef-cdc-token
+ */
+ public const TOKEN_CDC = 'CDC-token';
+
+ /**
+ * CDO (Comment Delimiter Open) token: ``).
+ *
+ * Comment Delimiter Close - legacy HTML comment syntax in CSS.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram
+ */
+ if (
+ $this->at + 2 < $this->length &&
+ '-' === $this->css[ $this->at + 1 ] &&
+ '>' === $this->css[ $this->at + 2 ]
+ ) {
+ // Consume them and return a .
+ $this->at += 3;
+ $this->token_type = self::TOKEN_CDC;
+ $this->token_length = 3;
+ return true;
+ }
+
+ // Otherwise, if the input stream starts with an ident sequence,
+ // reconsume the current input code point, consume an ident-like
+ // token, and return it.
+ if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) {
+ return $this->consume_ident_like();
+ }
+
+ // Otherwise, return a with its value set to the current input code point.
+ ++$this->at;
+ $this->token_type = self::TOKEN_DELIM;
+ $this->token_length = 1;
+ return true;
+ }
+
+ /*
+ * U+003C LESS-THAN SIGN (<)
+ * If followed by `!--`, this is a CDO token (`',
+ WP_CSS_Token_Processor::TOKEN_BAD_STRING => "\"\n",
+ WP_CSS_Token_Processor::TOKEN_BAD_URL => 'url(()',
+ );
+
+ /**
+ * Closing token type of each token type that opens a block.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var array
+ */
+ private const BLOCK_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,
+ );
+
+ /**
+ * Serializes tokens as CSS text that parses to the same CSS.
+ *
+ * This follows CSS Syntax Level 3 §9 Serialization:
+ *
+ * > The only requirement for serialization is that it must "round-trip" with parsing,
+ * > that is, parsing the stylesheet must produce the same data structures as
+ * > parsing, serializing, and parsing again,
+ * > except for consecutive s,
+ * > which may be collapsed into a single token.
+ *
+ * Comment tokens are dropped. Each run of whitespace, including a run
+ * broken by comments, is written as one space; a run is never removed.
+ *
+ * Tokens are written from their decoded values. Identifiers, function
+ * names, at-keywords, hash names, dimension units, strings and url values
+ * are escaped again, so `\66 oo` becomes `foo` and `"a&b"` becomes `"a\26 b"`.
+ *
+ * > A containing U+005C REVERSE SOLIDUS (\)
+ * > must be serialized as U+005C REVERSE SOLIDUS
+ * > followed by a newline.
+ * > (The tokenizer only ever emits such a token followed by a
+ * > that starts with a newline.)
+ *
+ * > A with the "unrestricted" type flag may not need
+ * > as much escaping as the same token with the "id" type flag.
+ *
+ * A hash with the "id" flag is escaped as an identifier, so `#\31 a` stays
+ * `#\31 a`. A hash with the "unrestricted" flag may start with a digit or
+ * `-`, so `#1a` stays `#1a`.
+ *
+ * > The unit of a may need escaping
+ * > to disambiguate with scientific notation.
+ *
+ * A unit that starts with `e`, an optional `-`, and a digit has the `e`
+ * escaped, so the unit `e3` is written `1\65 3`, not `1e3`.
+ *
+ * > For any consecutive pair of tokens,
+ * > if the first token shows up in the row headings of the following table,
+ * > and the second token shows up in the column headings,
+ * > and there’s a ✗ in the cell denoted by the intersection of the chosen row and column,
+ * > the pair of tokens must be serialized with a comment between them.
+ * >
+ * > Single characters in the row and column headings represent a with that value,
+ * > except for "(",
+ * > which represents a (-token.
+ *
+ * ┌────────────┬───────┬──────────┬─────┬─────────┬───┬────────┬────────────┬───────────┬─────┬───┬───┬───┐
+ * │ │ ident │ function │ url │ bad url │ - │ number │ percentage │ dimension │ CDC │ ( │ * │ % │
+ * ├────────────┼───────┼──────────┼─────┼─────────┼───┼────────┼────────────┼───────────┼─────┼───┼───┼───┤
+ * │ ident │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │
+ * │ at-keyword │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │
+ * │ hash │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │
+ * │ dimension │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │
+ * │ # │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │
+ * │ - │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │
+ * │ number │ ✗ │ ✗ │ ✗ │ ✗ │ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ ✗ │
+ * │ @ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ ✗ │ │ │ │
+ * │ . │ │ │ │ │ │ ✗ │ ✗ │ ✗ │ │ │ │ │
+ * │ + │ │ │ │ │ │ ✗ │ ✗ │ ✗ │ │ │ │ │
+ * │ / │ │ │ │ │ │ │ │ │ │ │ ✗ │ │
+ * └────────────┴───────┴──────────┴─────┴─────────┴───┴────────┴────────────┴───────────┴─────┴───┴───┴───┘
+ *
+ * Comments are not preserved, so an empty CSS comment is written between
+ * the tokens of a marked pair when no whitespace is between them.
+ *
+ * The table compares token types in pairs, so it misses two sequences that
+ * the tokenizer reads as a CDO or CDC token: `<`, `!` and a token that
+ * starts with `--`, which spell ``. An empty CSS comment is written before the last token
+ * of each.
+ *
+ * Bad-string and bad-url tokens have no value. A bad string is written as
+ * a quote followed by a newline, where the tokenizer ends one; a bad url is
+ * written as `url(()`. Both re-tokenize as the same bad token. If a later
+ * step replaces the newline after a bad string with a space, the quote
+ * starts a string that includes the text after it.
+ *
+ * Strings and urls cut off by the end of the input are written closed.
+ * Blocks still open at the end are closed in reverse order, so `a(b[c` is
+ * written `a(b[c])`.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * $processor = new WP_CSS_Token_Processor( 'url( a&b ) calc(1px + 2px' );
+ * $tokens = array();
+ * while ( $processor->next_token() ) {
+ * $tokens[] = $processor->get_token();
+ * }
+ * echo WP_CSS_Token_Serializer::serialize( $tokens );
+ * // Outputs: "url(a\26 b) calc(1px + 2px)"
+ * ```
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#serialization
+ *
+ * @since 7.3.0
+ *
+ * @param iterable $tokens Tokens read by WP_CSS_Token_Processor::get_token().
+ * @return string CSS text.
+ */
+ public static function serialize( iterable $tokens ): string {
+ $output = '';
+ $closers = array();
+
+ // Pair key of the last token written, or the whitespace type after a space.
+ $last = null;
+
+ // Text of the last two tokens written, or a space after whitespace.
+ $last_text = '';
+ $before_last_text = '';
+
+ foreach ( $tokens as $token ) {
+ $type = $token->get_type();
+
+ if ( WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) {
+ continue;
+ }
+
+ if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type ) {
+ if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $last ) {
+ $output .= ' ';
+ $last = WP_CSS_Token_Processor::TOKEN_WHITESPACE;
+ $before_last_text = $last_text;
+ $last_text = ' ';
+ }
+ continue;
+ }
+
+ $key = WP_CSS_Token_Processor::TOKEN_DELIM === $type ? $token->get_value() : $type;
+ $text = self::serialize_token( $token );
+
+ if (
+ ( null !== $last && isset( self::MARKED_PAIRS[ $last ] ) && in_array( $key, self::MARKED_PAIRS[ $last ], true ) ) ||
+ // The table misses `<`, `!`, `--x`, which reads as ``.
+ ( '<' === $before_last_text && '!' === $last_text && 0 === strncmp( $text, '--', 2 ) ) ||
+ ( '--' === $last_text && '>' === $text )
+ ) {
+ $output .= '/**/';
+ }
+
+ $output .= $text;
+ $last = $key;
+ $before_last_text = $last_text;
+ $last_text = $text;
+
+ if ( isset( self::BLOCK_CLOSERS[ $type ] ) ) {
+ $closers[] = self::BLOCK_CLOSERS[ $type ];
+ } elseif ( end( $closers ) === $type ) {
+ array_pop( $closers );
+ }
+ }
+
+ while ( array() !== $closers ) {
+ $output .= self::FIXED_TEXT[ array_pop( $closers ) ];
+ }
+
+ return $output;
+ }
+
+ /**
+ * Serializes one token other than whitespace or a comment.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param WP_CSS_Token $token Token.
+ * @return string CSS text.
+ */
+ private static function serialize_token( WP_CSS_Token $token ): string {
+ $value = $token->get_value();
+
+ switch ( $token->get_type() ) {
+ case WP_CSS_Token_Processor::TOKEN_IDENT:
+ return self::escape_ident( $value );
+
+ case WP_CSS_Token_Processor::TOKEN_FUNCTION:
+ return self::escape_ident( $value ) . '(';
+
+ case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD:
+ return '@' . self::escape_ident( $value );
+
+ case WP_CSS_Token_Processor::TOKEN_HASH:
+ return WP_CSS_Token_Processor::HASH_TOKEN_ID === $token->get_flag()
+ ? '#' . self::escape_ident( $value )
+ : '#' . self::escape_name( $value );
+
+ case WP_CSS_Token_Processor::TOKEN_STRING:
+ return self::escape_string( $value );
+
+ case WP_CSS_Token_Processor::TOKEN_URL:
+ return self::escape_url( $value );
+
+ case WP_CSS_Token_Processor::TOKEN_NUMBER:
+ return $value;
+
+ case WP_CSS_Token_Processor::TOKEN_PERCENTAGE:
+ return $value . '%';
+
+ case WP_CSS_Token_Processor::TOKEN_DIMENSION:
+ $unit = self::escape_ident( $token->get_unit() );
+ // A unit that starts with `e`, an optional `-`, and a digit would read as an exponent.
+ if ( 1 === preg_match( '/^[eE]-?[0-9]/', $unit ) ) {
+ $unit = sprintf( '\\%x ', ord( $unit[0] ) ) . substr( $unit, 1 );
+ }
+ return $value . $unit;
+
+ case WP_CSS_Token_Processor::TOKEN_DELIM:
+ return '\\' === $value ? "\\\n" : $value;
+
+ default:
+ return self::FIXED_TEXT[ $token->get_type() ];
+ }
+ }
+
+ /**
+ * Escapes a decoded value as identifier text.
+ *
+ * Characters not valid in CSS identifiers are hex-escaped, as are the
+ * characters escape_string() escapes for HTML and CSS.
+ *
+ * An empty value returns an empty string. Callers that require an ident
+ * token must reject an empty value.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#escaping
+ * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier
+ *
+ * @since 7.3.0
+ * @access private
+ * @ignore
+ *
+ * @param string $value Decoded value.
+ * @return string Identifier text.
+ */
+ public static function escape_ident( string $value ): string {
+ // A lone `-` cannot start an identifier.
+ if ( '-' === $value ) {
+ return '\\2d ';
+ }
+
+ // Neither can a digit or a `-` followed by a digit, so the digit is escaped.
+ $digit_at = ( isset( $value[0] ) && '-' === $value[0] ) ? 1 : 0;
+ if ( isset( $value[ $digit_at ] ) && $value[ $digit_at ] >= '0' && $value[ $digit_at ] <= '9' ) {
+ return substr( $value, 0, $digit_at ) . sprintf( '\\%x ', ord( $value[ $digit_at ] ) ) . self::escape_name( substr( $value, $digit_at + 1 ) );
+ }
+
+ return self::escape_name( $value );
+ }
+
+ /**
+ * Escapes a decoded value as a quoted string.
+ *
+ * Quotes, `\` and the control characters U+0001 to U+001F and U+007F are
+ * hex-escaped. So are `< > & , ; { }`, which may be problematic in HTML or
+ * for rudimentary CSS processors. The string reads back as the same value,
+ * except that NULL becomes U+FFFD.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#escaping
+ * @link https://drafts.csswg.org/cssom/#serialize-a-string
+ *
+ * @since 7.3.0
+ * @access private
+ * @ignore
+ *
+ * @see WP_CSS_Builder::string()
+ *
+ * @param string $value Decoded value.
+ * @return string Quoted string text.
+ */
+ public static function escape_string( string $value ): string {
+ $value = wp_scrub_utf8( $value );
+ $result = '';
+ $length = strlen( $value );
+
+ for ( $i = 0; $i < $length; $i++ ) {
+ $byte = ord( $value[ $i ] );
+
+ // CSS cannot represent NULL; preprocessing replaces it with U+FFFD.
+ if ( 0x00 === $byte ) {
+ $result .= "\u{FFFD}";
+ continue;
+ }
+
+ if (
+ $byte < 0x20 ||
+ 0x7F === $byte ||
+ false !== strpos( '"\'\\<>&,;{}', $value[ $i ] )
+ ) {
+ $result .= sprintf( '\\%x ', $byte );
+ continue;
+ }
+
+ $result .= $value[ $i ];
+ }
+
+ return "\"{$result}\"";
+ }
+
+ /**
+ * Escapes every code point that cannot appear in an identifier.
+ *
+ * Letters, digits, `-`, `_` and the non-ASCII ident code points are
+ * written as they are; every other code point is hex-escaped. Unlike
+ * escape_ident(), a leading digit or `-` is not escaped, as the name of
+ * a hash token with the "unrestricted" flag requires.
+ *
+ * The tokenizer reads every code point above U+007F as an identifier code
+ * point, as browsers do. CSS Syntax allows fewer, so the others are escaped
+ * and the output reads the same under both rules.
+ *
+ * @link https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param string $value Decoded name.
+ * @return string Escaped name.
+ */
+ private static function escape_name( string $value ): string {
+ $value = wp_scrub_utf8( $value );
+ $result = '';
+ $length = strlen( $value );
+
+ for ( $i = 0; $i < $length; $i++ ) {
+ $byte = ord( $value[ $i ] );
+
+ if ( 0x00 === $byte ) {
+ $result .= "\u{FFFD}";
+ continue;
+ }
+
+ if ( $byte >= 0x80 ) {
+ // The value is valid UTF-8, so the leading byte gives the sequence length.
+ $sequence_length = $byte >= 0xF0 ? 4 : ( $byte >= 0xE0 ? 3 : 2 );
+ $code_point = $byte & ( 0xFF >> ( $sequence_length + 1 ) );
+ for ( $j = 1; $j < $sequence_length; $j++ ) {
+ $code_point = ( $code_point << 6 ) | ( ord( $value[ $i + $j ] ) & 0x3F );
+ }
+
+ $result .= self::is_non_ascii_ident_code_point( $code_point )
+ ? substr( $value, $i, $sequence_length )
+ : sprintf( '\\%x ', $code_point );
+ $i += $sequence_length - 1;
+ continue;
+ }
+
+ if (
+ ( $byte >= 0x30 && $byte <= 0x39 ) || // 0-9
+ ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z
+ ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z
+ 0x5F === $byte || // _
+ 0x2D === $byte // -
+ ) {
+ $result .= $value[ $i ];
+ continue;
+ }
+
+ $result .= sprintf( '\\%x ', $byte );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Reports whether a code point above U+007F is a non-ASCII ident code point.
+ *
+ * @link https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param int $code_point Code point above U+007F.
+ * @return bool Whether the code point may appear unescaped in an identifier.
+ */
+ private static function is_non_ascii_ident_code_point( int $code_point ): bool {
+ return (
+ 0xB7 === $code_point ||
+ ( $code_point >= 0xC0 && $code_point <= 0xD6 ) ||
+ ( $code_point >= 0xD8 && $code_point <= 0xF6 ) ||
+ ( $code_point >= 0xF8 && $code_point <= 0x37D ) ||
+ ( $code_point >= 0x37F && $code_point <= 0x1FFF ) ||
+ 0x200C === $code_point ||
+ 0x200D === $code_point ||
+ 0x203F === $code_point ||
+ 0x2040 === $code_point ||
+ ( $code_point >= 0x2070 && $code_point <= 0x218F ) ||
+ ( $code_point >= 0x2C00 && $code_point <= 0x2FEF ) ||
+ ( $code_point >= 0x3001 && $code_point <= 0xD7FF ) ||
+ ( $code_point >= 0xF900 && $code_point <= 0xFDCF ) ||
+ ( $code_point >= 0xFDF0 && $code_point <= 0xFFFD ) ||
+ $code_point >= 0x10000
+ );
+ }
+
+ /**
+ * Escapes a decoded value as an unquoted url.
+ *
+ * The code points an unquoted url cannot contain are hex-escaped: quotes,
+ * parentheses, `\`, whitespace and non-printable code points. So are the
+ * code points escape_string() escapes for HTML and CSS: `< > & , ; { }`.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-url-token
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param string $value Decoded value.
+ * @return string Url text, including `url(` and `)`.
+ */
+ private static function escape_url( string $value ): string {
+ $value = wp_scrub_utf8( $value );
+ $result = '';
+ $length = strlen( $value );
+
+ for ( $i = 0; $i < $length; $i++ ) {
+ $byte = ord( $value[ $i ] );
+
+ if ( 0x00 === $byte ) {
+ $result .= "\u{FFFD}";
+ continue;
+ }
+
+ if (
+ $byte <= 0x20 ||
+ 0x7F === $byte ||
+ false !== strpos( '"\'()\\<>&,;{}', $value[ $i ] )
+ ) {
+ $result .= sprintf( '\\%x ', $byte );
+ continue;
+ }
+
+ $result .= $value[ $i ];
+ }
+
+ return "url({$result})";
+ }
+}
diff --git a/src/wp-includes/css-api/class-wp-css-token.php b/src/wp-includes/css-api/class-wp-css-token.php
new file mode 100644
index 0000000000000..55af708415637
--- /dev/null
+++ b/src/wp-includes/css-api/class-wp-css-token.php
@@ -0,0 +1,364 @@
+next_token() ) {
+ * $tokens[] = $processor->get_token();
+ * }
+ * echo $tokens[3]->get_type() . "\n";
+ * echo $tokens[3]->get_value();
+ * // Outputs:
+ * // string-token
+ * // Exo 2
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @see WP_CSS_Token_Processor::get_token()
+ */
+final class WP_CSS_Token {
+ /**
+ * CSS source the offsets index.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string
+ */
+ private $css;
+
+ /**
+ * Token type.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string
+ * @phpstan-var WP_CSS_Token_Processor::TOKEN_*
+ */
+ private $type;
+
+ /**
+ * Byte offset at which the token starts.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var int
+ */
+ private $start;
+
+ /**
+ * Byte length of the token.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var int
+ */
+ private $length;
+
+ /**
+ * Type flag of a hash, number or dimension token.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string|null
+ * @phpstan-var 'id'|'unrestricted'|'integer'|'number'|null
+ */
+ private $flag;
+
+ /**
+ * Decoded unit of a dimension token.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string|null
+ */
+ private $unit;
+
+ /**
+ * Byte offset at which the value of a string, url or comment token starts.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var int|null
+ */
+ private $value_start;
+
+ /**
+ * Byte length of the value of a string, url or comment token.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var int|null
+ */
+ private $value_length;
+
+ /**
+ * Whether the token ended at its closing delimiter.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var bool
+ */
+ private $is_terminated;
+
+ /**
+ * Decoded value, or false before the first call to get_value().
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string|null|false
+ */
+ private $value = false;
+
+ /**
+ * Constructor.
+ *
+ * Use WP_CSS_Token_Processor::get_token() to create tokens.
+ *
+ * @since 7.3.0
+ * @access private
+ * @ignore
+ *
+ * @param string $css CSS source the offsets index.
+ * @param string $type Token type, one of the WP_CSS_Token_Processor::TOKEN_* constants.
+ * @param int $start Byte offset at which the token starts.
+ * @param int $length Byte length of the token.
+ * @param string|null $flag Type flag of a hash, number or dimension token.
+ * @param string|null $unit Decoded unit of a dimension token.
+ * @param int|null $value_start Byte offset at which the value of a string, url or comment token starts.
+ * @param int|null $value_length Byte length of the value of a string, url or comment token.
+ * @param bool $is_terminated Whether the token ended at its closing delimiter.
+ */
+ public function __construct(
+ string $css,
+ string $type,
+ int $start,
+ int $length,
+ ?string $flag,
+ ?string $unit,
+ ?int $value_start,
+ ?int $value_length,
+ bool $is_terminated
+ ) {
+ $this->css = $css;
+ $this->type = $type;
+ $this->start = $start;
+ $this->length = $length;
+ $this->flag = $flag;
+ $this->unit = $unit;
+ $this->value_start = $value_start;
+ $this->value_length = $value_length;
+ $this->is_terminated = $is_terminated;
+ }
+
+ /**
+ * Gets the token type.
+ *
+ * @since 7.3.0
+ *
+ * @return string One of the WP_CSS_Token_Processor::TOKEN_* constants.
+ * @phpstan-return WP_CSS_Token_Processor::TOKEN_*
+ */
+ public function get_type(): string {
+ return $this->type;
+ }
+
+ /**
+ * Gets the type flag.
+ *
+ * Hash tokens have an "id" or "unrestricted" flag. Number and dimension
+ * tokens have an "integer" or "number" flag. Other tokens have none.
+ *
+ * @since 7.3.0
+ *
+ * @return string|null The type flag, or null.
+ * @phpstan-return 'id'|'unrestricted'|'integer'|'number'|null
+ */
+ public function get_flag(): ?string {
+ return $this->flag;
+ }
+
+ /**
+ * Gets the decoded value.
+ *
+ * The value is the token's text after input preprocessing and escape
+ * decoding, without its delimiters:
+ *
+ * - ident, delim: the whole token.
+ * - function: the name, without `(`.
+ * - at-keyword, hash: the name, without `@` or `#`.
+ * - string, url: the contents, without quotes or `url(` and `)`.
+ * - comment: the text between `/*` and `*` `/`. Comments contain no
+ * escapes, so only preprocessing applies.
+ * - number, percentage, dimension: the number as written in the source,
+ * without `%` or the unit. CSS Syntax 3 parses it as a number; this
+ * method keeps the text.
+ * - Every other type, including bad-string and bad-url: null.
+ *
+ * The value is decoded on the first call and kept for later calls.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * $processor = new WP_CSS_Token_Processor( '"a\\62 c"' );
+ * $processor->next_token();
+ * echo $processor->get_token()->get_value();
+ * // Outputs: abc
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#tokenization
+ *
+ * @return string|null The decoded value, or null for token types without a value.
+ */
+ public function get_value(): ?string {
+ if ( false === $this->value ) {
+ $this->value = $this->decode_value();
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * Gets the decoded unit of a dimension token.
+ *
+ * @since 7.3.0
+ *
+ * @return string|null The unit, or null for other token types.
+ */
+ public function get_unit(): ?string {
+ return $this->unit;
+ }
+
+ /**
+ * Gets the byte offset at which the token starts in the CSS source.
+ *
+ * @since 7.3.0
+ *
+ * @return int Byte offset.
+ */
+ public function get_start(): int {
+ return $this->start;
+ }
+
+ /**
+ * Gets the byte length of the token in the CSS source.
+ *
+ * @since 7.3.0
+ *
+ * @return int Byte length.
+ */
+ public function get_length(): int {
+ return $this->length;
+ }
+
+ /**
+ * Reports whether the token ended at its closing delimiter.
+ *
+ * Comment, string, url and bad-url tokens have a closing delimiter: `*` `/`,
+ * the matching quote, or `)`. When the input ends before the delimiter,
+ * the tokenizer still returns the token, and this method returns false.
+ * It returns true for every other token type, which have no closing
+ * delimiter.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * $processor = new WP_CSS_Token_Processor( '"abc' );
+ * $processor->next_token();
+ * $token = $processor->get_token();
+ * echo $token->get_type() . "\n";
+ * var_dump( $token->is_terminated() );
+ * // Outputs:
+ * // string-token
+ * // bool(false)
+ * ```
+ *
+ * @since 7.3.0
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-comment
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-string-token
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-url-token
+ *
+ * @return bool Whether the token ended at its closing delimiter.
+ */
+ public function is_terminated(): bool {
+ return $this->is_terminated;
+ }
+
+ /**
+ * Decodes the value from the CSS source.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @return string|null The decoded value, or null for token types without a value.
+ */
+ private function decode_value(): ?string {
+ switch ( $this->type ) {
+ case WP_CSS_Token_Processor::TOKEN_HASH:
+ case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD:
+ // The value starts after the `#` or `@`.
+ return WP_CSS_Token_Processor::decode_range( $this->css, $this->start + 1, $this->length - 1 );
+
+ case WP_CSS_Token_Processor::TOKEN_FUNCTION:
+ // The value ends before the `(`.
+ return WP_CSS_Token_Processor::decode_range( $this->css, $this->start, $this->length - 1 );
+
+ case WP_CSS_Token_Processor::TOKEN_IDENT:
+ case WP_CSS_Token_Processor::TOKEN_DELIM:
+ return WP_CSS_Token_Processor::decode_range( $this->css, $this->start, $this->length );
+
+ case WP_CSS_Token_Processor::TOKEN_STRING:
+ return WP_CSS_Token_Processor::decode_range( $this->css, $this->value_start, $this->value_length, true );
+
+ case WP_CSS_Token_Processor::TOKEN_URL:
+ return WP_CSS_Token_Processor::decode_range( $this->css, $this->value_start, $this->value_length );
+
+ case WP_CSS_Token_Processor::TOKEN_COMMENT:
+ return WP_CSS_Token_Processor::preprocess_range( $this->css, $this->value_start, $this->value_length );
+
+ case WP_CSS_Token_Processor::TOKEN_NUMBER:
+ // Numbers are ASCII and have no escapes.
+ return substr( $this->css, $this->start, $this->length );
+
+ case WP_CSS_Token_Processor::TOKEN_PERCENTAGE:
+ // The value ends before the `%`.
+ return substr( $this->css, $this->start, $this->length - 1 );
+
+ case WP_CSS_Token_Processor::TOKEN_DIMENSION:
+ // The value ends before the unit.
+ return substr( WP_CSS_Token_Processor::decode_range( $this->css, $this->start, $this->length ), 0, -strlen( $this->unit ) );
+
+ default:
+ return null;
+ }
+ }
+}
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..04c8486f3eef2
--- /dev/null
+++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php
@@ -0,0 +1,1659 @@
+next_declaration( 'background-image' ) ) {
+ * $styles->remove_declaration();
+ * }
+ * $styles->append_declaration( 'background-image', WP_CSS_Builder::url( 'https://example.com/b.png' ) );
+ * echo $styles->get_updated_style();
+ * // Outputs: color: red; background-image: url("https://example.com/b.png");
+ * ```
+ *
+ * The cursor moves forward only. After one `next_declaration( $name )` loop
+ * ends, a second one finds nothing. To set several properties, remove all of
+ * their declarations in one `next_declaration()` loop that compares
+ * get_property_name() with each name, then append each declaration.
+ *
+ * Example:
+ *
+ * ```php interactive
+ * $values = array(
+ * 'color' => 'green',
+ * 'width' => '2px',
+ * );
+ * $styles = new WP_HTML_Style_Attribute_Processor( 'color: red; width: 1px; COLOR: blue; height: 2px' );
+ * while ( $styles->next_declaration() ) {
+ * if ( isset( $values[ $styles->get_property_name() ] ) ) {
+ * $styles->remove_declaration();
+ * }
+ * }
+ * foreach ( $values as $name => $value ) {
+ * $styles->append_declaration( $name, $value );
+ * }
+ * echo $styles->get_updated_style();
+ * // Outputs: height: 2px; color: green; width: 2px;
+ * ```
+ *
+ * The appended declaration is last, so it overrides earlier declarations that
+ * set the same thing, shorthands included:
+ *
+ * - In `background-image: url(a); background: red`, setting `background-image`
+ * to `url(b)` gives `background: red; background-image: url(b);`, which shows
+ * image b.
+ * - In `background-color: blue`, setting `background` to `url(c)` gives
+ * `background-color: blue; background: url(c);`. The shorthand resets the
+ * color.
+ *
+ * set_value() keeps the declaration where it is, so a later shorthand still
+ * overrides it.
+ *
+ * @since 7.3.0
+ *
+ * @see WP_CSS_Builder
+ */
+class WP_HTML_Style_Attribute_Processor {
+ /**
+ * CSS whitespace characters.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string
+ */
+ private const WHITESPACE = " \t\n\r\f";
+
+ /**
+ * Decoded style attribute value.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var string
+ */
+ private $style;
+
+ /**
+ * CSS tokens of the style attribute value.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var WP_CSS_Token[]
+ */
+ private $tokens = array();
+
+ /**
+ * Parsed declarations.
+ *
+ * Each declaration records byte offsets into the style text. The offsets
+ * of the second declaration in this style are:
+ *
+ * color: red; width: 1px !important; top: 0
+ * ╰────────────────────╯ start to after
+ * ╰─╯ value_start to value_end
+ * ╰────────╯ important_start to important_end
+ *
+ * `value_tokens` is the range of token indexes, end exclusive, of the
+ * value. The `important_*` offsets are null when the declaration has no
+ * `!important` priority.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var array
+ */
+ private $declarations = array();
+
+ /**
+ * Whether the style attribute value has been parsed.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var bool
+ */
+ private $parsed = false;
+
+ /**
+ * Index of the current declaration, or -1 before the first declaration.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var int
+ */
+ private $current_declaration = -1;
+
+ /**
+ * Whether the current declaration was removed and no new declaration has been selected.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @var bool
+ */
+ private $current_declaration_removed = false;
+
+ /**
+ * Constructor.
+ *
+ * `true` and `null` give an empty style.
+ *
+ * @since 7.3.0
+ *
+ * @param string|true|null $decoded_css_text Decoded CSS text value from a style attribute.
+ */
+ public function __construct( $decoded_css_text ) {
+ if ( true === $decoded_css_text || null === $decoded_css_text ) {
+ $decoded_css_text = '';
+ }
+
+ if ( ! is_string( $decoded_css_text ) ) {
+ throw new TypeError( __METHOD__ . '(): Argument #1 ($decoded_css_text) must be of type string|true|null' );
+ }
+
+ $this->style = $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.3.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.3.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.3.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:;`.
+ *
+ * color: red blue !important;
+ * ╰───┬───╯
+ * The value tokens are `red`, a whitespace token and `blue`.
+ *
+ * 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 the constructor.
+ *
+ * @since 7.3.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.3.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.3.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 ( ! $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;
+ }
+
+ $updated = $this->verify_current_declaration_replacement(
+ $declaration,
+ $declaration['start'],
+ $declaration['after'] - $declaration['start'],
+ $text,
+ $is_important,
+ $parsed_value
+ );
+ if ( null === $updated ) {
+ return false;
+ }
+
+ $this->apply_update( $updated, $this->current_declaration );
+
+ return true;
+ }
+
+ /**
+ * Sets the !important priority of the current declaration.
+ *
+ * @since 7.3.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_text = ' !important';
+ $updated = $this->verify_current_declaration_replacement(
+ $declaration,
+ $declaration['value_end'],
+ 0,
+ $insert_text,
+ true
+ );
+ if ( null === $updated ) {
+ $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 ) {
+ return false;
+ }
+
+ $updated = $this->verify_current_declaration_replacement(
+ $declaration,
+ $declaration['after'],
+ 0,
+ $eof_repair . $insert_text,
+ true,
+ trim( $current_value . $eof_repair, self::WHITESPACE )
+ );
+ }
+ } else {
+ if ( null === $declaration['important_start'] || null === $declaration['important_end'] ) {
+ return false;
+ }
+
+ $updated = $this->verify_current_declaration_replacement(
+ $declaration,
+ $declaration['important_start'],
+ $declaration['important_end'] - $declaration['important_start'],
+ '',
+ false
+ );
+ }
+
+ if ( null === $updated ) {
+ return false;
+ }
+
+ $this->apply_update( $updated, $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.3.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 ) ) ? ' ' : '';
+
+ $updated = $this->verify_declaration_removal( $this->current_declaration, $remove_start, $remove_end - $remove_start, $replacement );
+ if ( null === $updated ) {
+ return false;
+ }
+
+ $this->apply_update( $updated, $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.3.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;
+ }
+
+ $updated = $this->verify_append( $repair_at, $eof_repair, $separator, $declaration_text, $old_declaration_count, $name, $parsed_value, $important );
+ if ( null === $updated ) {
+ return false;
+ }
+
+ $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_update(
+ $updated,
+ $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.3.0
+ *
+ * @return string Updated decoded CSS text value for the style attribute.
+ */
+ public function get_updated_style(): string {
+ return $this->style;
+ }
+
+ /**
+ * Parses CSS tokens and declarations from the style attribute value.
+ *
+ * @since 7.3.0
+ * @ignore
+ */
+ private function parse(): void {
+ $this->tokens = array();
+ $this->declarations = array();
+
+ $processor = new WP_CSS_Token_Processor( $this->style );
+
+ 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.
+ *
+ * @since 7.3.0
+ * @ignore
+ */
+ private function ensure_parsed(): void {
+ if ( $this->parsed ) {
+ return;
+ }
+
+ $this->parse();
+ }
+
+ /**
+ * Parses the token list according to the CSS declaration-list shape.
+ *
+ * @since 7.3.0
+ * @ignore
+ */
+ private function parse_declaration_list(): void {
+ $index = 0;
+ $count = count( $this->tokens );
+
+ while ( $index < $count ) {
+ $index = $this->skip_ignored_tokens( $index );
+
+ while ( $index < $count && WP_CSS_Token_Processor::TOKEN_SEMICOLON === $this->tokens[ $index ]->get_type() ) {
+ ++$index;
+ $index = $this->skip_ignored_tokens( $index );
+ }
+
+ if ( $index >= $count ) {
+ break;
+ }
+
+ if ( WP_CSS_Token_Processor::TOKEN_AT_KEYWORD === $this->tokens[ $index ]->get_type() ) {
+ $index = $this->consume_at_rule( $index );
+ continue;
+ }
+
+ list( $declaration_end, $after_declaration, $has_semicolon ) = $this->consume_declaration_segment( $index );
+ $declaration = $this->parse_declaration_segment( $index, $declaration_end, $after_declaration );
+
+ if ( null !== $declaration ) {
+ $this->declarations[] = $declaration;
+ }
+
+ $index = $declaration_end + ( $has_semicolon ? 1 : 0 );
+ }
+ }
+
+ /**
+ * Consumes an at-rule and returns the token index after it.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param int $index At-keyword token index.
+ * @return int Token index after the at-rule.
+ */
+ private function consume_at_rule( int $index ): int {
+ $count = count( $this->tokens );
+ ++$index;
+
+ while ( $index < $count ) {
+ $token = $this->tokens[ $index ];
+
+ if ( WP_CSS_Token_Processor::TOKEN_SEMICOLON === $token->get_type() ) {
+ return $index + 1;
+ }
+
+ 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() ) {
+ $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() ) {
+ $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() ) {
+ $index = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Consumes a simple block.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param int $index Token index for the opening token.
+ * @param string $closing_type Expected closing token type.
+ * @return int Token index after the block.
+ */
+ private function consume_simple_block( int $index, string $closing_type ): int {
+ $count = count( $this->tokens );
+ ++$index;
+
+ while ( $index < $count ) {
+ $token = $this->tokens[ $index ];
+
+ if ( $closing_type === $token->get_type() ) {
+ return $index + 1;
+ }
+
+ if ( WP_CSS_Token_Processor::TOKEN_FUNCTION === $token->get_type() ) {
+ $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() ) {
+ $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() ) {
+ $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() ) {
+ $index = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Consumes a declaration candidate up to the next top-level semicolon or EOF.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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() ) {
+ $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() ) {
+ $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() ) {
+ $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() ) {
+ $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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, start:int, after: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 $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,
+ 'start' => $name_token->get_start(),
+ 'after' => $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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() ) {
+ $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() ) {
+ $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() ) {
+ $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() ) {
+ $index = $this->consume_simple_block( $index, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE );
+ continue;
+ }
+
+ ++$index;
+ }
+
+ return $top_level_tokens;
+ }
+
+ /**
+ * Skips ignored CSS tokens.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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;
+ }
+
+ /**
+ * Checks whether a token is ignored by declaration-list grammar.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @return array{name:string, start:int, after: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 ];
+ }
+
+ /**
+ * Parses the style with a replacement and checks that it preserves the current declaration's structure.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param array{name:string, start:int, after: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 self|null Parsed processor for the replaced style, or null when the replacement is not safe.
+ */
+ private function verify_current_declaration_replacement( array $declaration, int $start, int $length, string $text, bool $expected_important, ?string $expected_value = null ): ?self {
+ $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 null;
+ }
+
+ $updated_declaration = $candidate->declarations[ $this->current_declaration ];
+ if (
+ $updated_declaration['name'] !== $declaration['name'] ||
+ $updated_declaration['important'] !== $expected_important
+ ) {
+ return null;
+ }
+
+ $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
+ );
+
+ $is_safe = null === $expected_value ? $old_value === $new_value : $expected_value === $new_value;
+
+ return $is_safe ? $candidate : null;
+ }
+
+ /**
+ * Parses the style with a declaration removed and checks that the remaining structure is preserved.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 self|null Parsed processor for the style without the declaration, or null when the removal is not safe.
+ */
+ private function verify_declaration_removal( int $removed_declaration, int $start, int $length, string $text ): ?self {
+ $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 null;
+ }
+
+ $candidate_index = 0;
+ foreach ( $this->declarations as $index => $declaration ) {
+ if ( $removed_declaration === $index ) {
+ continue;
+ }
+
+ if ( ! isset( $candidate->declarations[ $candidate_index ] ) ) {
+ return null;
+ }
+
+ $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 null;
+ }
+
+ ++$candidate_index;
+ }
+
+ return $candidate;
+ }
+
+ /**
+ * Gets the parsed value source from a serialized declaration.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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
+ );
+ }
+
+ /**
+ * Replaces the style text, tokens and declarations with those of a verified update.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param self $updated Parsed processor for the updated style.
+ * @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_update( self $updated, int $current_declaration, bool $current_declaration_removed = false ): void {
+ $this->style = $updated->style;
+ $this->tokens = $updated->tokens;
+ $this->declarations = $updated->declarations;
+ $this->parsed = true;
+
+ $this->current_declaration = $current_declaration;
+ $this->current_declaration_removed = $current_declaration_removed;
+
+ if ( $this->current_declaration >= count( $this->declarations ) ) {
+ $this->current_declaration = count( $this->declarations );
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 {
+ $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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() ) {
+ $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() ) {
+ $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() ) {
+ $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() ) {
+ $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 ) );
+ }
+
+ /**
+ * Parses the style with a declaration appended and checks that it parses 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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 self|null Parsed processor for the style with the declaration, or null when it does not parse as a new declaration.
+ */
+ private function verify_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 ): ?self {
+ $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 null;
+ }
+
+ 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
+ );
+
+ $is_parseable = (
+ $expected_name === $declaration['name'] &&
+ $expected_value === $parsed_value &&
+ $expected_important === $declaration['important']
+ );
+
+ return $is_parseable ? $candidate : null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Formats a declaration as `name: value;` or `name: value !important;`.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @param string $value CSS declaration value.
+ * @return string Value text.
+ */
+ private function serialize_value( string $value ): string {
+ $processor = new WP_CSS_Token_Processor( $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 = new WP_CSS_Token_Processor( $property_name );
+ if ( ! $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 = new WP_CSS_Token_Processor( $property_name . ': sentinel' );
+ if ( ! $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.
+ *
+ * @since 7.3.0
+ * @ignore
+ *
+ * @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 = new WP_CSS_Token_Processor( $value . ';' . $sentinel_property . ':1' );
+
+ $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()->get_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 92f9456aa02ca..29a1d298e6449 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -289,6 +289,11 @@
require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php';
require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php';
require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php';
+require ABSPATH . WPINC . '/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 . '/css-api/class-wp-css-builder.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/data/css-api/LICENSE b/tests/phpunit/data/css-api/LICENSE
new file mode 100644
index 0000000000000..13b1ea364b27e
--- /dev/null
+++ b/tests/phpunit/data/css-api/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Romain Menke
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tests/phpunit/data/css-api/README.md b/tests/phpunit/data/css-api/README.md
new file mode 100644
index 0000000000000..93da314e4ef01
--- /dev/null
+++ b/tests/phpunit/data/css-api/README.md
@@ -0,0 +1,42 @@
+# CSS tokenizer test corpus
+
+This directory contains a third-party test corpus used for testing the WordPress CSS API tokenizer.
+
+`css-test-cases.json` derives from the npm package
+[`@rmenke/css-tokenizer-tests`](https://www.npmjs.com/package/@rmenke/css-tokenizer-tests)
+by Romain Menke, MIT licensed (see `LICENSE`). The package source is on GitHub at
+[romainmenke/css-tokenizer-tests](https://github.com/romainmenke/css-tokenizer-tests).
+
+The 185 cases match version 1.3.0 of the package, whose test content is identical to
+version 1.2.0. Version 1.4.0 adds 102 cases that are not in this file.
+
+## Transform
+
+The upstream corpus maps a case name to `{ css, tokens }`. Each upstream token has
+`type`, `raw`, `startIndex`, `endIndex` and `structured`. This file keeps the case names,
+`css`, `type` and `raw` unchanged and transforms the rest:
+
+- `startIndex` and `endIndex` are byte offsets into the UTF-8 source. Upstream uses
+ JavaScript string indexes (UTF-16 code units).
+- `value` is `structured.value` flattened onto the token. Numeric values are the
+ number's source text as a string (`"10e2"`, not `1000`); for dimension and
+ percentage tokens the unit or `%` is removed. A comment token, which has no
+ structured data upstream, has the text between its delimiters after input
+ preprocessing. Other tokens without structured data have `value: null`.
+- `unit` is `structured.unit`, present on dimension tokens only.
+- `numberType` is `structured.type` on number and dimension tokens. The upstream
+ `structured.type` of hash tokens (`id` or `unrestricted`) and `signCharacter` are
+ not kept.
+
+Three cases carry WordPress's token expectations where the upstream package contradicts
+CSS Syntax Level 3: `tests/ident/0007` (`-§`), `tests/ident/0008` (`-×`) and
+`tests/fuzz/b69ece36-057f-4450-9423-a1661787bce6`. In each, a non-ASCII code point or
+U+0000 (which preprocessing replaces with U+FFFD) is an ident code point, so the
+hyphen or identifier before it continues as one ident token. Upstream splits them into
+delim tokens.
+
+## Updating
+
+There is no conversion script. To update, take `testCorpus` from the package's
+`index.cjs`, apply the transform above, set `normalized` for each token per the
+specification, and update the version noted in this README.
diff --git a/tests/phpunit/data/css-api/css-test-cases.json b/tests/phpunit/data/css-api/css-test-cases.json
new file mode 100644
index 0000000000000..30e0dc8cdcccd
--- /dev/null
+++ b/tests/phpunit/data/css-api/css-test-cases.json
@@ -0,0 +1,4491 @@
+{
+ "tests/at-keyword/0001": {
+ "css": "@foo\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@foo",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0002": {
+ "css": "@--\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@--",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "--"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0003": {
+ "css": "@-1\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "@",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "@"
+ },
+ {
+ "type": "number-token",
+ "raw": "-1",
+ "startIndex": 1,
+ "endIndex": 3,
+ "value": "-1",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0004": {
+ "css": "@--1\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@--1",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "--1"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0005": {
+ "css": "@\\@\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@\\@",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "@"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0006": {
+ "css": "@_\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@_",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "_"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0007": {
+ "css": "@\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "@",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "@"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0008": {
+ "css": "pvA3@\\\neBnP\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "pvA3",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "pvA3"
+ },
+ {
+ "type": "delim-token",
+ "raw": "@",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": "@"
+ },
+ {
+ "type": "delim-token",
+ "raw": "\\",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": "\\"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "eBnP",
+ "startIndex": 7,
+ "endIndex": 11,
+ "value": "eBnP"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/at-keyword/0009": {
+ "css": "@aa𐀀\n",
+ "tokens": [
+ {
+ "type": "at-keyword-token",
+ "raw": "@aa𐀀",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "aa𐀀"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-string/0001": {
+ "css": "\"foo\n\"\n",
+ "tokens": [
+ {
+ "type": "bad-string-token",
+ "raw": "\"foo",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ },
+ {
+ "type": "bad-string-token",
+ "raw": "\"",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-string/0002": {
+ "css": "\"foo\\\n\"\n",
+ "tokens": [
+ {
+ "type": "string-token",
+ "raw": "\"foo\\\n\"",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-string/0003": {
+ "css": "\"foo\r\n\"\n",
+ "tokens": [
+ {
+ "type": "bad-string-token",
+ "raw": "\"foo",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\r\n",
+ "startIndex": 4,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "bad-string-token",
+ "raw": "\"",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-string/0004": {
+ "css": "\"foo\\\r\n\"\n",
+ "tokens": [
+ {
+ "type": "string-token",
+ "raw": "\"foo\\\r\n\"",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-string/0005": {
+ "css": "\"aa𐀀\n",
+ "tokens": [
+ {
+ "type": "bad-string-token",
+ "raw": "\"aa𐀀",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0001": {
+ "css": "url(\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "url(\n",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": ""
+ }
+ ]
+ },
+ "tests/bad-url/0002": {
+ "css": "url( a\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "url( a\n",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "a"
+ }
+ ]
+ },
+ "tests/bad-url/0003": {
+ "css": "url( a a\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url( a a\n",
+ "startIndex": 0,
+ "endIndex": 9,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0004": {
+ "css": "url( a a)\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url( a a)",
+ "startIndex": 0,
+ "endIndex": 9,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 9,
+ "endIndex": 10,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0005": {
+ "css": "url( a a\\)\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url( a a\\)\n",
+ "startIndex": 0,
+ "endIndex": 11,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0006": {
+ "css": "url( \\\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url( \\\n",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0007": {
+ "css": "url(a'')\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url(a'')",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": null
+ }
+ ]
+ },
+ "tests/bad-url/0008": {
+ "css": "url(a\")\n",
+ "tokens": [
+ {
+ "type": "bad-url-token",
+ "raw": "url(a\")",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/colon/0001": {
+ "css": ":\n",
+ "tokens": [
+ {
+ "type": "colon-token",
+ "raw": ":",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/comma/0001": {
+ "css": ",\n",
+ "tokens": [
+ {
+ "type": "comma-token",
+ "raw": ",",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/comment/0001": {
+ "css": "/* a comment */\n",
+ "tokens": [
+ {
+ "type": "comment",
+ "raw": "/* a comment */",
+ "startIndex": 0,
+ "endIndex": 15,
+ "value": " a comment "
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 15,
+ "endIndex": 16,
+ "value": null
+ }
+ ]
+ },
+ "tests/comment/0002": {
+ "css": "/* a comment ",
+ "tokens": [
+ {
+ "type": "comment",
+ "raw": "/* a comment ",
+ "startIndex": 0,
+ "endIndex": 13,
+ "value": " a comment "
+ }
+ ]
+ },
+ "tests/comment/0003": {
+ "css": "a/**/b\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "a",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "a"
+ },
+ {
+ "type": "comment",
+ "raw": "/**/",
+ "startIndex": 1,
+ "endIndex": 5,
+ "value": ""
+ },
+ {
+ "type": "ident-token",
+ "raw": "b",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": "b"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/comment/0004": {
+ "css": "/*\\*/*/\n",
+ "tokens": [
+ {
+ "type": "comment",
+ "raw": "/*\\*/",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "\\"
+ },
+ {
+ "type": "delim-token",
+ "raw": "*",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": "*"
+ },
+ {
+ "type": "delim-token",
+ "raw": "/",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": "/"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/comment/0005": {
+ "css": "/* a comment *",
+ "tokens": [
+ {
+ "type": "comment",
+ "raw": "/* a comment *",
+ "startIndex": 0,
+ "endIndex": 14,
+ "value": " a comment *"
+ }
+ ]
+ },
+ "tests/comment/0006": {
+ "css": "/*a𐀀*/\n",
+ "tokens": [
+ {
+ "type": "comment",
+ "raw": "/*a𐀀*/",
+ "startIndex": 0,
+ "endIndex": 9,
+ "value": "a𐀀"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 9,
+ "endIndex": 10,
+ "value": null
+ }
+ ]
+ },
+ "tests/digit/0001": {
+ "css": "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": "0",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "0",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "1",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": "1",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "2",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": "2",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "3",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": "3",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "4",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": "4",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 9,
+ "endIndex": 10,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "5",
+ "startIndex": 10,
+ "endIndex": 11,
+ "value": "5",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "6",
+ "startIndex": 12,
+ "endIndex": 13,
+ "value": "6",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 13,
+ "endIndex": 14,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "7",
+ "startIndex": 14,
+ "endIndex": 15,
+ "value": "7",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 15,
+ "endIndex": 16,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "8",
+ "startIndex": 16,
+ "endIndex": 17,
+ "value": "8",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 17,
+ "endIndex": 18,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "9",
+ "startIndex": 18,
+ "endIndex": 19,
+ "value": "9",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 19,
+ "endIndex": 20,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0001": {
+ "css": "10px\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10px",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "10",
+ "unit": "px",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0002": {
+ "css": "10\\70 x\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10\\70 x",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "10",
+ "unit": "px",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0003": {
+ "css": "10--custom-px\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10--custom-px",
+ "startIndex": 0,
+ "endIndex": 13,
+ "value": "10",
+ "unit": "--custom-px",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 13,
+ "endIndex": 14,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0004": {
+ "css": "10e2px\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10e2px",
+ "startIndex": 0,
+ "endIndex": 6,
+ "value": "10e2",
+ "unit": "px",
+ "numberType": "number"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0005": {
+ "css": "10E2PX\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10E2PX",
+ "startIndex": 0,
+ "endIndex": 6,
+ "value": "10E2",
+ "unit": "PX",
+ "numberType": "number"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0006": {
+ "css": "10\\0\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10\\0\n",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "10",
+ "unit": "�",
+ "numberType": "integer"
+ }
+ ]
+ },
+ "tests/dimension/0007": {
+ "css": "10a𐀀\n",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10a𐀀",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "10",
+ "unit": "a𐀀",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/dimension/0008": {
+ "css": "10a\u0000",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "10a\u0000",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "10",
+ "unit": "a�",
+ "numberType": "integer"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0001": {
+ "css": "\\",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "�"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0002": {
+ "css": "\\0",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\0",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "�"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0003": {
+ "css": "\\\\",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\\\",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "\\"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0004": {
+ "css": "\\0a b\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\0a b",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "\nb"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0005": {
+ "css": "\\0ab \n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\0ab ",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "«"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0006": {
+ "css": "\\0ab (foo)\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "\\0ab (",
+ "startIndex": 0,
+ "endIndex": 6,
+ "value": "«"
+ },
+ {
+ "type": "ident-token",
+ "raw": "foo",
+ "startIndex": 6,
+ "endIndex": 9,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 9,
+ "endIndex": 10,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 10,
+ "endIndex": 11,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0007": {
+ "css": "\\0ab (foo)\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\0ab ",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "«"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "(-token",
+ "raw": "(",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "foo",
+ "startIndex": 7,
+ "endIndex": 10,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 10,
+ "endIndex": 11,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0008": {
+ "css": "\\0000ab\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\0000ab\n",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "«"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0009": {
+ "css": "\\00000ab\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\00000ab",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "\nb"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0010": {
+ "css": "\\110000\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\110000\n",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "�"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0011": {
+ "css": "\\00D800\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\00D800\n",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "�"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0012": {
+ "css": "\\00DFFF\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\00DFFF\n",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "�"
+ }
+ ]
+ },
+ "tests/escaped-code-point/0013": {
+ "css": "\\\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "\\",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "\\"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0014": {
+ "css": "\\\u0000\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\\u0000",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "�"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0015": {
+ "css": "\\\u0000\b\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "\\\u0000",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "�"
+ },
+ {
+ "type": "delim-token",
+ "raw": "\b",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": "\b"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/escaped-code-point/0016": {
+ "css": "\"a\\12\r\nb\"",
+ "tokens": [
+ {
+ "type": "string-token",
+ "raw": "\"a\\12\r\nb\"",
+ "startIndex": 0,
+ "endIndex": 9,
+ "value": "a\u0012b"
+ }
+ ]
+ },
+ "tests/full-stop/0001": {
+ "css": ".\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": ".",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "."
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/full-stop/0002": {
+ "css": ".a\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": ".",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "."
+ },
+ {
+ "type": "ident-token",
+ "raw": "a",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": "a"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/full-stop/0003": {
+ "css": ".1\n",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": ".1",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": ".1",
+ "numberType": "number"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/fuzz/01a166c0-ca20-43a5-9ab0-0984e4a5362b": {
+ "css": "4waPtwEEGH\\\u0000jV3zM6hh6w30N0PC 7m8KM0HcWGOPw28Gt(r19",
+ "tokens": [
+ {
+ "type": "dimension-token",
+ "raw": "4waPtwEEGH\\\u0000jV3zM6hh6w30N0PC",
+ "startIndex": 0,
+ "endIndex": 28,
+ "value": "4",
+ "unit": "waPtwEEGH�jV3zM6hh6w30N0PC",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 28,
+ "endIndex": 29,
+ "value": null
+ },
+ {
+ "type": "dimension-token",
+ "raw": "7m8KM0HcWGOPw28Gt",
+ "startIndex": 29,
+ "endIndex": 46,
+ "value": "7",
+ "unit": "m8KM0HcWGOPw28Gt",
+ "numberType": "integer"
+ },
+ {
+ "type": "(-token",
+ "raw": "(",
+ "startIndex": 46,
+ "endIndex": 47,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "r19",
+ "startIndex": 47,
+ "endIndex": 50,
+ "value": "r19"
+ }
+ ]
+ },
+ "tests/fuzz/2abe9406-c063-4e9a-85ac-b13660671553": {
+ "css": "ak]P0A}808G\"lQh{R5M!QyOWE}oC2{2K TIa9}zb2oXWREY]0aj5J\\\r\nBJ5CO-16W5H7noF 19䀹41H3e8Z9%tg[O5AHEY24xh'9\"\"c34Q\"iiC0e45Da5f\"F5X3\"o(",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "ak",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "ak"
+ },
+ {
+ "type": "]-token",
+ "raw": "]",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "P0A",
+ "startIndex": 3,
+ "endIndex": 6,
+ "value": "P0A"
+ },
+ {
+ "type": "}-token",
+ "raw": "}",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "dimension-token",
+ "raw": "808G",
+ "startIndex": 7,
+ "endIndex": 11,
+ "value": "808",
+ "unit": "G",
+ "numberType": "integer"
+ },
+ {
+ "type": "string-token",
+ "raw": "\"lQh{R5M!QyOWE}oC2{2K TIa9}zb2oXWREY]0aj5J\\\r\nBJ5CO-16W5H7noF 19䀹41H3e8Z9%tg[O5AHEY24xh'9\"",
+ "startIndex": 11,
+ "endIndex": 102,
+ "value": "lQh{R5M!QyOWE}oC2{2K TIa9}zb2oXWREY]0aj5JBJ5CO-16W5H7noF 19䀹41H3e8Z9%tg[O5AHEY24xh'9"
+ },
+ {
+ "type": "string-token",
+ "raw": "\"c34Q\"",
+ "startIndex": 102,
+ "endIndex": 108,
+ "value": "c34Q"
+ },
+ {
+ "type": "ident-token",
+ "raw": "iiC0e45Da5f",
+ "startIndex": 108,
+ "endIndex": 119,
+ "value": "iiC0e45Da5f"
+ },
+ {
+ "type": "string-token",
+ "raw": "\"F5X3\"",
+ "startIndex": 119,
+ "endIndex": 125,
+ "value": "F5X3"
+ },
+ {
+ "type": "function-token",
+ "raw": "o(",
+ "startIndex": 125,
+ "endIndex": 127,
+ "value": "o"
+ }
+ ]
+ },
+ "tests/fuzz/4e630a47-507b-4b79-b00f-57f7dc1cc79d": {
+ "css": "\u000e7rSD6I5L1lglVRlL2X7BbEk\\3HCd\r94 \\\u0000skoW25d4%l64UUskN\"pHun\"!",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "\u000e",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "\u000e"
+ },
+ {
+ "type": "dimension-token",
+ "raw": "7rSD6I5L1lglVRlL2X7BbEk\\3HCd",
+ "startIndex": 1,
+ "endIndex": 29,
+ "value": "7",
+ "unit": "rSD6I5L1lglVRlL2X7BbEk\u0003HCd",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\r",
+ "startIndex": 29,
+ "endIndex": 30,
+ "value": null
+ },
+ {
+ "type": "number-token",
+ "raw": "94",
+ "startIndex": 30,
+ "endIndex": 32,
+ "value": "94",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 32,
+ "endIndex": 33,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "\\\u0000skoW25d4",
+ "startIndex": 33,
+ "endIndex": 43,
+ "value": "�skoW25d4"
+ },
+ {
+ "type": "delim-token",
+ "raw": "%",
+ "startIndex": 43,
+ "endIndex": 44,
+ "value": "%"
+ },
+ {
+ "type": "ident-token",
+ "raw": "l64UUskN",
+ "startIndex": 44,
+ "endIndex": 52,
+ "value": "l64UUskN"
+ },
+ {
+ "type": "string-token",
+ "raw": "\"pHun\"",
+ "startIndex": 52,
+ "endIndex": 58,
+ "value": "pHun"
+ },
+ {
+ "type": "delim-token",
+ "raw": "!",
+ "startIndex": 58,
+ "endIndex": 59,
+ "value": "!"
+ }
+ ]
+ },
+ "tests/fuzz/4f865903-e4dd-4a0b-83ed-e630cfa9dcca": {
+ "css": "gzO0{(p{DzQ7\u0000(a1;r1iN7w)",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "gzO0",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "gzO0"
+ },
+ {
+ "type": "{-token",
+ "raw": "{",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ },
+ {
+ "type": "(-token",
+ "raw": "(",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "p",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": "p"
+ },
+ {
+ "type": "{-token",
+ "raw": "{",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ },
+ {
+ "type": "function-token",
+ "raw": "DzQ7\u0000(",
+ "startIndex": 8,
+ "endIndex": 14,
+ "value": "DzQ7�"
+ },
+ {
+ "type": "ident-token",
+ "raw": "a1",
+ "startIndex": 14,
+ "endIndex": 16,
+ "value": "a1"
+ },
+ {
+ "type": "semicolon-token",
+ "raw": ";",
+ "startIndex": 16,
+ "endIndex": 17,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "r1iN7w",
+ "startIndex": 17,
+ "endIndex": 23,
+ "value": "r1iN7w"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 23,
+ "endIndex": 24,
+ "value": null
+ }
+ ]
+ },
+ "tests/fuzz/5181013c-60ab-483b-9c06-fb32c7e1e7e8": {
+ "css": "565'E{z\u0000U\u001fEG2}2Verb>nj3TVk3mu7wX1J\b.H\u000bi1Ga8f5 dserqydJ3\"xj398xy.W\" uHQbv7Bw1NtF;N3PwNY7Vx00BF o\"4CXzvP\"{594 6r}8QQKNQw135i1\\\r\nrey\thg7[5%rBK8RUC64Lu␌17O{E\\90873u}1O3vx4gHTC55Q9i4\"V3Vx4\"7r(34L]F\"ns2pPf\"V7b)EOBGH8rdC7\"\u000eVJ4OQ[ 9jtoMdINgS7o�206vo72kTcKkZR9wl30G'vK\ndhCEs3tValX ",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": "565",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "565",
+ "numberType": "integer"
+ },
+ {
+ "type": "string-token",
+ "raw": "'E{z\u0000U\u001fEG2}2Verb>nj3TVk3mu7wX1J\b.H\u000bi1Ga8f5 dserqydJ3\"xj398xy.W\" uHQbv7Bw1NtF;N3PwNY7Vx00BF o\"4CXzvP\"{594 6r}8QQKNQw135i1\\\r\nrey\thg7[5%rBK8RUC64Lu␌17O{E\\90873u}1O3vx4gHTC55Q9i4\"V3Vx4\"7r(34L]F\"ns2pPf\"V7b)EOBGH8rdC7\"\u000eVJ4OQ[ 9jtoMdINgS7o�206vo72kTcKkZR9wl30G'",
+ "startIndex": 3,
+ "endIndex": 263,
+ "value": "E{z�U\u001fEG2}2Verb>nj3TVk3mu7wX1J\b.H\u000bi1Ga8f5 dserqydJ3\"xj398xy.W\" uHQbv7Bw1NtF;N3PwNY7Vx00BF o\"4CXzvP\"{594 6r}8QQKNQw135i1rey\thg7[5%rBK8RUC64Lu␌17O{Eu}1O3vx4gHTC55Q9i4\"V3Vx4\"7r(34L]F\"ns2pPf\"V7b)EOBGH8rdC7\"\u000eVJ4OQ[ 9jtoMdINgS7o�206vo72kTcKkZR9wl30G"
+ },
+ {
+ "type": "ident-token",
+ "raw": "vK",
+ "startIndex": 263,
+ "endIndex": 265,
+ "value": "vK"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 265,
+ "endIndex": 266,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "dhCEs3tValX",
+ "startIndex": 266,
+ "endIndex": 277,
+ "value": "dhCEs3tValX"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 277,
+ "endIndex": 278,
+ "value": null
+ }
+ ]
+ },
+ "tests/fuzz/6d07fc79-586f-4efa-a0a2-37d4dd3beb09": {
+ "css": "FWUNqr7uv8300nz\b,8lU0j6B186kh \u00009 \u000eGZafxf2GIhL9%",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "FWUNqr7uv8300nz",
+ "startIndex": 0,
+ "endIndex": 15,
+ "value": "FWUNqr7uv8300nz"
+ },
+ {
+ "type": "delim-token",
+ "raw": "\b",
+ "startIndex": 15,
+ "endIndex": 16,
+ "value": "\b"
+ },
+ {
+ "type": "comma-token",
+ "raw": ",",
+ "startIndex": 16,
+ "endIndex": 17,
+ "value": null
+ },
+ {
+ "type": "dimension-token",
+ "raw": "8lU0j6B186kh",
+ "startIndex": 17,
+ "endIndex": 29,
+ "value": "8",
+ "unit": "lU0j6B186kh",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 29,
+ "endIndex": 30,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "\u00009",
+ "startIndex": 30,
+ "endIndex": 32,
+ "value": "�9"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 32,
+ "endIndex": 33,
+ "value": null
+ },
+ {
+ "type": "delim-token",
+ "raw": "\u000e",
+ "startIndex": 33,
+ "endIndex": 34,
+ "value": "\u000e"
+ },
+ {
+ "type": "ident-token",
+ "raw": "GZafxf2GIhL9",
+ "startIndex": 34,
+ "endIndex": 46,
+ "value": "GZafxf2GIhL9"
+ },
+ {
+ "type": "delim-token",
+ "raw": "%",
+ "startIndex": 46,
+ "endIndex": 47,
+ "value": "%"
+ }
+ ]
+ },
+ "tests/fuzz/7f49c8fc-8292-4a3e-828b-b5d028a80d5f": {
+ "css": "FZ 0B120h5QUbNbmTD2K8mAD傿i+Yv9V0KS14Ng18ag'\\\r\n{Xu)k2a76}y4\\6fb9ONI\\",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": ">",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": ">"
+ },
+ {
+ "type": "ident-token",
+ "raw": "u",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": "u"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "k2a76",
+ "startIndex": 3,
+ "endIndex": 8,
+ "value": "k2a76"
+ },
+ {
+ "type": "}-token",
+ "raw": "}",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": null
+ },
+ {
+ "type": "ident-token",
+ "raw": "y4\\6fb9ONI\\",
+ "startIndex": 9,
+ "endIndex": 20,
+ "value": "y4澹ONI�"
+ }
+ ]
+ },
+ "tests/hash/0001": {
+ "css": "#1\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#1",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "1"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0002": {
+ "css": "#-2\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#-2",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "-2"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0003": {
+ "css": "#--3\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#--3",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "--3"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0004": {
+ "css": "#---4\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#---4",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "---4"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0005": {
+ "css": "#a\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#a",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "a"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0006": {
+ "css": "#-b\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#-b",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "-b"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0007": {
+ "css": "#--c\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#--c",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "--c"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0008": {
+ "css": "#---d\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#---d",
+ "startIndex": 0,
+ "endIndex": 5,
+ "value": "---d"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 5,
+ "endIndex": 6,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0009": {
+ "css": "#_\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#_",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "_"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0010": {
+ "css": "#_1\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#_1",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "_1"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0011": {
+ "css": "#-\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#-",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "-"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0012": {
+ "css": "#+\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "#",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "#"
+ },
+ {
+ "type": "delim-token",
+ "raw": "+",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": "+"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0013": {
+ "css": "##\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "#",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "#"
+ },
+ {
+ "type": "delim-token",
+ "raw": "#",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": "#"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hash/0014": {
+ "css": "#",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "#",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "#"
+ }
+ ]
+ },
+ "tests/hash/0015": {
+ "css": "#aa𐀀\n",
+ "tokens": [
+ {
+ "type": "hash-token",
+ "raw": "#aa𐀀",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "aa𐀀"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0001": {
+ "css": "-\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "-",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "-"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0002": {
+ "css": "-1\n",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": "-1",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "-1",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0003": {
+ "css": "-.1\n",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": "-.1",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "-.1",
+ "numberType": "number"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0004": {
+ "css": "--1\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "--1",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "--1"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0005": {
+ "css": "-0\n",
+ "tokens": [
+ {
+ "type": "number-token",
+ "raw": "-0",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "-0",
+ "numberType": "integer"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/hyphen-minus/0006": {
+ "css": "-->\n",
+ "tokens": [
+ {
+ "type": "CDC-token",
+ "raw": "-->",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0001": {
+ "css": "foo\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "foo",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0002": {
+ "css": "--\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "--",
+ "startIndex": 0,
+ "endIndex": 2,
+ "value": "--"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0003": {
+ "css": "--0\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "--0",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "--0"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0004": {
+ "css": "-\\\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "-",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "-"
+ },
+ {
+ "type": "delim-token",
+ "raw": "\\",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": "\\"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 2,
+ "endIndex": 3,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0005": {
+ "css": "-\\ \n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "-\\ ",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "- "
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0006": {
+ "css": "--💅\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "--💅",
+ "startIndex": 0,
+ "endIndex": 6,
+ "value": "--💅"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 6,
+ "endIndex": 7,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0007": {
+ "css": "-§\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "-§",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "-§"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0008": {
+ "css": "-×\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "-×",
+ "startIndex": 0,
+ "endIndex": 3,
+ "value": "-×"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 3,
+ "endIndex": 4,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident/0009": {
+ "css": "--a𐀀\n",
+ "tokens": [
+ {
+ "type": "ident-token",
+ "raw": "--a𐀀",
+ "startIndex": 0,
+ "endIndex": 7,
+ "value": "--a𐀀"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 7,
+ "endIndex": 8,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0001": {
+ "css": "url(foo)\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "url(foo)",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 8,
+ "endIndex": 9,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0002": {
+ "css": "\\75 Rl(foo)\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "\\75 Rl(foo)",
+ "startIndex": 0,
+ "endIndex": 11,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0003": {
+ "css": "uR\\6c (foo)\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "uR\\6c (foo)",
+ "startIndex": 0,
+ "endIndex": 11,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0004": {
+ "css": "url('foo')\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "url(",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "url"
+ },
+ {
+ "type": "string-token",
+ "raw": "'foo'",
+ "startIndex": 4,
+ "endIndex": 9,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 9,
+ "endIndex": 10,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 10,
+ "endIndex": 11,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0005": {
+ "css": "url( 'foo')\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "url(",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "url"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 4,
+ "endIndex": 5,
+ "value": null
+ },
+ {
+ "type": "string-token",
+ "raw": "'foo'",
+ "startIndex": 5,
+ "endIndex": 10,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 10,
+ "endIndex": 11,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0006": {
+ "css": "url( 'foo')\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "url(",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "url"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 4,
+ "endIndex": 6,
+ "value": null
+ },
+ {
+ "type": "string-token",
+ "raw": "'foo'",
+ "startIndex": 6,
+ "endIndex": 11,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 12,
+ "endIndex": 13,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0007": {
+ "css": "url( 'foo')\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "url(",
+ "startIndex": 0,
+ "endIndex": 4,
+ "value": "url"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 4,
+ "endIndex": 7,
+ "value": null
+ },
+ {
+ "type": "string-token",
+ "raw": "'foo'",
+ "startIndex": 7,
+ "endIndex": 12,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 12,
+ "endIndex": 13,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 13,
+ "endIndex": 14,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0008": {
+ "css": "not-url( 'foo')\n",
+ "tokens": [
+ {
+ "type": "function-token",
+ "raw": "not-url(",
+ "startIndex": 0,
+ "endIndex": 8,
+ "value": "not-url"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": " ",
+ "startIndex": 8,
+ "endIndex": 11,
+ "value": null
+ },
+ {
+ "type": "string-token",
+ "raw": "'foo'",
+ "startIndex": 11,
+ "endIndex": 16,
+ "value": "foo"
+ },
+ {
+ "type": ")-token",
+ "raw": ")",
+ "startIndex": 16,
+ "endIndex": 17,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 17,
+ "endIndex": 18,
+ "value": null
+ }
+ ]
+ },
+ "tests/ident-like/0009": {
+ "css": "url( foo)\n",
+ "tokens": [
+ {
+ "type": "url-token",
+ "raw": "url( foo)",
+ "startIndex": 0,
+ "endIndex": 11,
+ "value": "foo"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 11,
+ "endIndex": 12,
+ "value": null
+ }
+ ]
+ },
+ "tests/left-curly-bracket/0001": {
+ "css": "{\n",
+ "tokens": [
+ {
+ "type": "{-token",
+ "raw": "{",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/left-parenthesis/0001": {
+ "css": "(\n",
+ "tokens": [
+ {
+ "type": "(-token",
+ "raw": "(",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/left-square-bracket/0001": {
+ "css": "[\n",
+ "tokens": [
+ {
+ "type": "[-token",
+ "raw": "[",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": null
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/less-than/0001": {
+ "css": "<\n",
+ "tokens": [
+ {
+ "type": "delim-token",
+ "raw": "<",
+ "startIndex": 0,
+ "endIndex": 1,
+ "value": "<"
+ },
+ {
+ "type": "whitespace-token",
+ "raw": "\n",
+ "startIndex": 1,
+ "endIndex": 2,
+ "value": null
+ }
+ ]
+ },
+ "tests/less-than/0002": {
+ "css": "',
'',
@@ -153,26 +157,34 @@ public function test_build_equivalent_html_semantic_tree_with_equivalent_html( $
public function data_build_equivalent_html_semantic_tree_with_non_equivalent_html() {
return array(
- 'Different attributes' => array(
+ 'Different attributes' => array(
'
',
'
',
),
- 'Different class names' => array(
+ 'Different class names' => array(
'
',
'
',
),
- 'Different styles' => array(
+ 'Different styles' => array(
'
',
'
',
),
- 'Different comments' => array(
+ 'Different comments' => array(
'',
'',
),
- 'Semantically relevant whitespace' => array(
+ 'Semantically relevant whitespace' => array(
'Test
',
'Test
',
),
+ 'Different URLs in styles' => array(
+ 'Test
',
+ 'Test
',
+ ),
+ 'Style text that is not a declaration' => array(
+ 'Test
',
+ 'Test
',
+ ),
);
}
diff --git a/tests/phpunit/tests/css-api/wpCssBuilder.php b/tests/phpunit/tests/css-api/wpCssBuilder.php
new file mode 100644
index 0000000000000..bba5a66e05ee8
--- /dev/null
+++ b/tests/phpunit/tests/css-api/wpCssBuilder.php
@@ -0,0 +1,146 @@
+assertCount( 1, $tokens );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[0]->get_type() );
+ $this->assertTrue( $tokens[0]->is_terminated() );
+ $this->assertSame( $expected_value, $tokens[0]->get_value() );
+ }
+
+ /**
+ * Tests that url() writes a url function holding one string token that reads back as the value.
+ *
+ * @ticket 65738
+ * @dataProvider data_values
+ *
+ * @covers ::url
+ */
+ public function test_url_reads_back_as_the_value( string $value, string $expected_value ): void {
+ $tokens = self::tokenize( WP_CSS_Builder::url( $value ) );
+
+ $this->assertCount( 3, $tokens );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_FUNCTION, $tokens[0]->get_type() );
+ $this->assertSame( 'url', $tokens[0]->get_value() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[1]->get_type() );
+ $this->assertSame( $expected_value, $tokens[1]->get_value() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, $tokens[2]->get_type() );
+ }
+
+ /**
+ * Tests that ident() writes one ident token that reads back as the value.
+ *
+ * @ticket 65738
+ * @dataProvider data_values
+ *
+ * @covers ::ident
+ */
+ public function test_ident_reads_back_as_the_value( string $value, string $expected_value ): void {
+ $tokens = self::tokenize( WP_CSS_Builder::ident( $value ) );
+
+ $this->assertCount( 1, $tokens );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $tokens[0]->get_type() );
+ $this->assertSame( $expected_value, $tokens[0]->get_value() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_values(): array {
+ return array(
+ 'plain' => array( 'abc', 'abc' ),
+ 'url with a query' => array( 'https://example.com/a?b=1&c=2', 'https://example.com/a?b=1&c=2' ),
+ 'parentheses and spaces' => array( 'https://example.com/a (1).jpg', 'https://example.com/a (1).jpg' ),
+ 'double quote' => array( 'a"b', 'a"b' ),
+ 'single quote' => array( "a'b", "a'b" ),
+ 'backslash' => array( 'a\\b', 'a\\b' ),
+ 'declaration delimiters' => array( 'a;b:c}d{e', 'a;b:c}d{e' ),
+ 'html delimiters' => array( '&', '&' ),
+ 'newline and tab' => array( "a\nb\tc\r\fd", "a\nb\tc\r\fd" ),
+ 'leading digit' => array( '1a', '1a' ),
+ 'leading hyphen digit' => array( '-1a', '-1a' ),
+ 'lone hyphen' => array( '-', '-' ),
+ 'custom property name' => array( '--a', '--a' ),
+ 'non-ascii' => array( 'café 😀', 'café 😀' ),
+ 'null byte' => array( "a\0b", "a\u{FFFD}b" ),
+ 'invalid utf-8' => array( "a\xFFb", "a\u{FFFD}b" ),
+ );
+ }
+
+ /**
+ * Tests that string() and url() write an empty value as an empty string.
+ *
+ * @ticket 65738
+ *
+ * @covers ::string
+ * @covers ::url
+ */
+ public function test_empty_string_and_url(): void {
+ $this->assertSame( '""', WP_CSS_Builder::string( '' ) );
+ $this->assertSame( 'url("")', WP_CSS_Builder::url( '' ) );
+ }
+
+ /**
+ * Tests that ident() returns an empty string for an empty value.
+ *
+ * @ticket 65738
+ *
+ * @covers ::ident
+ */
+ public function test_empty_ident(): void {
+ $this->assertSame( '', WP_CSS_Builder::ident( '' ) );
+ }
+
+ /**
+ * Tests the examples in the docblocks.
+ *
+ * @ticket 65738
+ *
+ * @covers ::string
+ * @covers ::url
+ * @covers ::ident
+ */
+ public function test_docblock_examples(): void {
+ $this->assertSame(
+ 'url("https://example.com/a (1) \\22 b\\22 .jpg")',
+ WP_CSS_Builder::url( 'https://example.com/a (1) "b".jpg' )
+ );
+ $this->assertSame( '"Say \\22 hi\\22 "', WP_CSS_Builder::string( 'Say "hi"' ) );
+ $this->assertSame( 'url("https://example.com/a(1).png")', WP_CSS_Builder::url( 'https://example.com/a(1).png' ) );
+ $this->assertSame( '\\31 st\\20 Font', WP_CSS_Builder::ident( '1st Font' ) );
+ }
+
+ /**
+ * Tokenizes CSS.
+ *
+ * @param string $css CSS to tokenize.
+ * @return WP_CSS_Token[] Tokens.
+ */
+ private static function tokenize( string $css ): array {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $tokens = array();
+ while ( $processor->next_token() ) {
+ $tokens[] = $processor->get_token();
+ }
+ return $tokens;
+ }
+}
diff --git a/tests/phpunit/tests/css-api/wpCssToken.php b/tests/phpunit/tests/css-api/wpCssToken.php
new file mode 100644
index 0000000000000..10fa39d42fb23
--- /dev/null
+++ b/tests/phpunit/tests/css-api/wpCssToken.php
@@ -0,0 +1,219 @@
+next_token() ) {
+ $tokens[] = $processor->get_token();
+ }
+
+ $actual_tokens = array();
+ foreach ( $tokens as $token ) {
+ $actual_token = array(
+ 'type' => $token->get_type(),
+ 'startIndex' => $token->get_start(),
+ 'endIndex' => $token->get_start() + $token->get_length(),
+ 'value' => $token->get_value(),
+ );
+ if ( null !== $token->get_unit() ) {
+ $actual_token['unit'] = $token->get_unit();
+ }
+ if (
+ WP_CSS_Token_Processor::TOKEN_NUMBER === $token->get_type() ||
+ WP_CSS_Token_Processor::TOKEN_DIMENSION === $token->get_type()
+ ) {
+ $actual_token['numberType'] = $token->get_flag();
+ }
+ $actual_tokens[] = $actual_token;
+ }
+
+ $expected_tokens = array_map(
+ static function ( array $token ): array {
+ unset( $token['raw'], $token['normalized'] );
+ return $token;
+ },
+ $expected_tokens
+ );
+
+ $this->assertSame( $expected_tokens, $actual_tokens );
+ }
+
+ /**
+ * Data provider for test_get_token_matches_spec().
+ *
+ * @return array[]
+ */
+ public static function data_corpus(): array {
+ return json_decode( file_get_contents( DIR_TESTDATA . '/css-api/css-test-cases.json' ), true );
+ }
+
+ /**
+ * Tests that get_token() returns null when the processor has no current token.
+ *
+ * @ticket 65738
+ *
+ * @covers WP_CSS_Token_Processor::get_token
+ */
+ public function test_get_token_returns_null_without_a_current_token(): void {
+ $processor = new WP_CSS_Token_Processor( 'a' );
+ $this->assertNull( $processor->get_token(), 'There is no token before next_token().' );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertInstanceOf( WP_CSS_Token::class, $processor->get_token() );
+
+ $this->assertFalse( $processor->next_token() );
+ $this->assertNull( $processor->get_token(), 'There is no token after the last one.' );
+ }
+
+ /**
+ * Tests that a token decodes its value after the processor has moved on.
+ *
+ * @ticket 65738
+ *
+ * @covers ::get_value
+ */
+ public function test_value_decodes_after_the_processor_moves_on(): void {
+ $processor = new WP_CSS_Token_Processor( '\\66 oo "b\\61 r" url(b\\61 z) #\\31 a 1\\70 x' );
+ $tokens = array();
+ while ( $processor->next_token() ) {
+ $tokens[] = $processor->get_token();
+ }
+
+ $values = array();
+ foreach ( $tokens as $token ) {
+ $values[] = array( $token->get_type(), $token->get_value() );
+ }
+
+ $this->assertSame(
+ array(
+ array( WP_CSS_Token_Processor::TOKEN_IDENT, 'foo' ),
+ array( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null ),
+ array( WP_CSS_Token_Processor::TOKEN_STRING, 'bar' ),
+ array( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null ),
+ array( WP_CSS_Token_Processor::TOKEN_URL, 'baz' ),
+ array( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null ),
+ array( WP_CSS_Token_Processor::TOKEN_HASH, '1a' ),
+ array( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null ),
+ array( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1' ),
+ ),
+ $values
+ );
+ $this->assertSame( 'px', $tokens[8]->get_unit() );
+ }
+
+ /**
+ * Tests that a token reports the type flag of the token it was read from.
+ *
+ * @ticket 65738
+ * @dataProvider data_flag
+ *
+ * @covers ::get_flag
+ */
+ public function test_flag( string $css, string $expected_type, ?string $expected_flag ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $this->assertTrue( $processor->next_token() );
+
+ $token = $processor->get_token();
+ $this->assertSame( $expected_type, $token->get_type() );
+ $this->assertSame( $expected_flag, $token->get_flag() );
+ }
+
+ /**
+ * Data provider for test_flag().
+ *
+ * @return array[]
+ */
+ public static function data_flag(): array {
+ return array(
+ 'hash id' => array( '#a1', WP_CSS_Token_Processor::TOKEN_HASH, WP_CSS_Token_Processor::HASH_TOKEN_ID ),
+ 'hash escaped digit' => array( '#\\31 a', WP_CSS_Token_Processor::TOKEN_HASH, WP_CSS_Token_Processor::HASH_TOKEN_ID ),
+ 'hash unrestricted' => array( '#1a', WP_CSS_Token_Processor::TOKEN_HASH, WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED ),
+ 'integer' => array( '12', WP_CSS_Token_Processor::TOKEN_NUMBER, 'integer' ),
+ 'number' => array( '1.5', WP_CSS_Token_Processor::TOKEN_NUMBER, 'number' ),
+ 'integer dimension' => array( '1px', WP_CSS_Token_Processor::TOKEN_DIMENSION, 'integer' ),
+ 'number dimension' => array( '1e2px', WP_CSS_Token_Processor::TOKEN_DIMENSION, 'number' ),
+ 'percentage' => array( '50%', WP_CSS_Token_Processor::TOKEN_PERCENTAGE, null ),
+ 'ident' => array( 'a', WP_CSS_Token_Processor::TOKEN_IDENT, null ),
+ );
+ }
+
+ /**
+ * Tests that a token reports whether it ended at its closing delimiter.
+ *
+ * @ticket 65738
+ * @dataProvider data_is_terminated
+ *
+ * @covers ::is_terminated
+ */
+ public function test_is_terminated( string $css, bool $expected ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $this->assertTrue( $processor->next_token() );
+
+ $token = $processor->get_token();
+ $processor->next_token();
+ $this->assertSame( $expected, $token->is_terminated(), 'The flag must not change when the processor moves on.' );
+ }
+
+ /**
+ * Data provider for test_is_terminated().
+ *
+ * @return array[]
+ */
+ public static function data_is_terminated(): array {
+ return array(
+ 'string terminated' => array( '"a" b', true ),
+ 'string unterminated' => array( '"a', false ),
+ 'url terminated' => array( 'url(a) b', true ),
+ 'url unterminated' => array( 'url(a', false ),
+ 'comment terminated' => array( '/* a */ b', true ),
+ 'comment unterminated' => array( '/* a', false ),
+ 'ident' => array( 'a b', true ),
+ );
+ }
+
+ /**
+ * Tests that the value of a comment token is the text between its delimiters.
+ *
+ * @ticket 65738
+ * @dataProvider data_comment_value
+ *
+ * @covers ::get_value
+ */
+ public function test_comment_value( string $css, string $expected ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_COMMENT, $processor->get_token_type() );
+
+ $this->assertSame( $expected, $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Data provider for test_comment_value().
+ *
+ * @return array[]
+ */
+ public static function data_comment_value(): array {
+ return array(
+ 'terminated' => array( '/* a */', ' a ' ),
+ 'unterminated' => array( '/* a', ' a' ),
+ 'opener star does not close' => array( '/*/', '/' ),
+ 'no escapes, newlines preprocessed' => array( "/*\\41\r\n*/", "\\41\n" ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/css-api/wpCssTokenProcessor.php b/tests/phpunit/tests/css-api/wpCssTokenProcessor.php
new file mode 100644
index 0000000000000..c4c190e87cec9
--- /dev/null
+++ b/tests/phpunit/tests/css-api/wpCssTokenProcessor.php
@@ -0,0 +1,2622 @@
+collect_tokens( $css );
+ $this->assertSame( $expected_tokens, $actual_tokens );
+ }
+
+ /**
+ * Provides the test cases derived from the @rmenke/css-tokenizer-tests corpus (MIT).
+ *
+ * @link https://github.com/romainmenke/css-tokenizer-tests/
+ * @return array
+ */
+ public static function corpus_provider(): array {
+ return json_decode( file_get_contents( DIR_TESTDATA . '/css-api/css-test-cases.json' ), true );
+ }
+
+ /**
+ * Tokenizes CSS and collects every token into an array.
+ *
+ * @param string $css CSS to tokenize.
+ * @param string[]|null $keys Optional. Keys to keep on each token. Default all.
+ * @return array Array of tokens with type, raw, startIndex, endIndex, value, unit and numberType.
+ */
+ public static function collect_tokens( string $css, ?array $keys = null ): array {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $tokens = array();
+
+ while ( $processor->next_token() ) {
+ $type = $processor->get_token_type();
+
+ $byte_start = $processor->get_token_start();
+ $byte_end = $byte_start + $processor->get_token_length();
+
+ $token = array(
+ 'type' => $type,
+ 'raw' => substr( $css, $byte_start, $processor->get_token_length() ),
+ 'startIndex' => $byte_start,
+ 'endIndex' => $byte_end,
+ 'value' => $processor->get_token()->get_value(),
+ );
+ if ( null !== $processor->get_token()->get_unit() ) {
+ $token['unit'] = $processor->get_token()->get_unit();
+ }
+ if (
+ WP_CSS_Token_Processor::TOKEN_NUMBER === $type ||
+ WP_CSS_Token_Processor::TOKEN_DIMENSION === $type
+ ) {
+ $token['numberType'] = $processor->get_token()->get_flag();
+ }
+
+ if ( null !== $keys ) {
+ $token = array_intersect_key( $token, array_flip( $keys ) );
+ }
+
+ $tokens[] = $token;
+ }
+
+ return $tokens;
+ }
+
+ /**
+ * Tests that backslash-newline in a string token contributes nothing to the value.
+ *
+ * @ticket 65738
+ * @dataProvider data_string_backslash_newline
+ */
+ public function test_string_backslash_newline( string $css, string $expected_value ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() );
+ $this->assertSame( $expected_value, $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Data provider for test_string_backslash_newline().
+ *
+ * @return array[]
+ */
+ public static function data_string_backslash_newline(): array {
+ return array(
+ 'backslash-LF' => array( "'str\\\ning'", 'string' ),
+ 'backslash-FF' => array( "'str\\\fing'", 'string' ),
+ 'backslash-CR' => array( "'str\\\ring'", 'string' ),
+ 'backslash-CRLF' => array( "'str\\\r\ning'", 'string' ),
+ );
+ }
+
+ /**
+ * Tests that backslash-EOF in a string token contributes nothing to the value.
+ *
+ * @ticket 65738
+ */
+ public function test_string_backslash_eof(): void {
+ $processor = new WP_CSS_Token_Processor( "'string\\" );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() );
+ $this->assertSame( 'string', $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Tests that backslash-newline in an unquoted URL produces a bad-url token.
+ *
+ * @ticket 65738
+ * @dataProvider data_url_backslash_newline
+ */
+ public function test_url_backslash_newline( string $css ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_BAD_URL, $processor->get_token_type() );
+ }
+
+ /**
+ * Data provider for test_url_backslash_newline().
+ *
+ * @return array[]
+ */
+ public static function data_url_backslash_newline(): array {
+ return array(
+ 'backslash-LF' => array( "url(ab\\\ncd)" ),
+ 'backslash-FF' => array( "url(ab\\\fcd)" ),
+ 'backslash-CR' => array( "url(ab\\\rcd)" ),
+ 'backslash-CRLF' => array( "url(ab\\\r\ncd)" ),
+ );
+ }
+
+ /**
+ * Tests that backslash-EOF in an unquoted URL produces U+FFFD in the value.
+ *
+ * @ticket 65738
+ */
+ public function test_url_backslash_eof(): void {
+ $processor = new WP_CSS_Token_Processor( 'url(string\\' );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_URL, $processor->get_token_type() );
+ $this->assertSame( "string\u{FFFD}", $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Tests that preprocessing replaces NUL in the token value and keeps the source bytes.
+ *
+ * NUL becomes U+FFFD before tokenization, so it is valid inside an unquoted
+ * URL. Other control bytes still produce a bad-url token.
+ *
+ * @ticket 65738
+ * @dataProvider data_preprocessed_token_keeps_source_bytes
+ */
+ public function test_preprocessed_token_keeps_source_bytes( string $css, string $expected_type, ?string $expected_value ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( $expected_type, $processor->get_token_type() );
+ $this->assertSame( $expected_value, $processor->get_token()->get_value() );
+ $this->assertSame( 0, $processor->get_token_start() );
+ $this->assertSame( strlen( $css ), $processor->get_token_length() );
+ $this->assertFalse( $processor->next_token() );
+ }
+
+ /**
+ * Data provider for test_preprocessed_token_keeps_source_bytes().
+ *
+ * @return Generator
+ */
+ public static function data_preprocessed_token_keeps_source_bytes(): Generator {
+ yield 'NUL as the URL' => array( "url(\x00)", WP_CSS_Token_Processor::TOKEN_URL, "\u{FFFD}" );
+ yield 'NUL before text' => array( "url(\x00a)", WP_CSS_Token_Processor::TOKEN_URL, "\u{FFFD}a" );
+ yield 'NUL inside text' => array( "url(a\x00b)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}b" );
+ yield 'NUL before closing parenthesis' => array( "url(a\x00)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}" );
+ yield 'NUL before EOF' => array( "url(a\x00", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}" );
+ yield 'consecutive NUL bytes' => array( "url(a\x00\x00b)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}\u{FFFD}b" );
+ yield 'escaped literal NUL' => array( "url(a\\\x00b)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}b" );
+ yield 'zero escape' => array( 'url(a\0 b)', WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}b" );
+ yield 'zero escape followed by CRLF' => array( "url(a\\0\r\nb)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}b" );
+ yield 'NUL followed by trailing CRLF' => array( "url(a\x00\r\n)", WP_CSS_Token_Processor::TOKEN_URL, "a\u{FFFD}" );
+ yield 'NUL in a string' => array( "\"a\x00b\"", WP_CSS_Token_Processor::TOKEN_STRING, "a\u{FFFD}b" );
+ yield 'NUL in a name' => array( "a\x00b", WP_CSS_Token_Processor::TOKEN_IDENT, "a\u{FFFD}b" );
+ yield 'NUL after trailing whitespace' => array( "url(a \x00)", WP_CSS_Token_Processor::TOKEN_BAD_URL, null );
+ yield 'NUL after a forbidden control' => array( "url(a\x01\x00b)", WP_CSS_Token_Processor::TOKEN_BAD_URL, null );
+
+ foreach ( array( "\t", "\n", "\r", "\f", "\r\n" ) as $whitespace ) {
+ yield 'internal whitespace ' . bin2hex( $whitespace ) => array( "url(a{$whitespace}b)", WP_CSS_Token_Processor::TOKEN_BAD_URL, null );
+ }
+
+ foreach ( array_merge( range( 0x01, 0x08 ), array( 0x0B ), range( 0x0E, 0x1F ), array( 0x7F ) ) as $byte ) {
+ yield 'forbidden control ' . dechex( $byte ) => array( 'url(a' . chr( $byte ) . 'b)', WP_CSS_Token_Processor::TOKEN_BAD_URL, null );
+ }
+ }
+
+ /**
+ * Tests that backslash-newline stops an ident sequence.
+ *
+ * @ticket 65738
+ * @dataProvider data_ident_backslash_newline
+ */
+ public function test_ident_backslash_newline( string $css ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $processor->get_token_type() );
+ $this->assertSame( 'abc', $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Data provider for test_ident_backslash_newline().
+ *
+ * @return array[]
+ */
+ public static function data_ident_backslash_newline(): array {
+ return array(
+ 'backslash-LF' => array( "abc\\\n" ),
+ 'backslash-FF' => array( "abc\\\f" ),
+ 'backslash-CR' => array( "abc\\\r" ),
+ 'backslash-CRLF' => array( "abc\\\r\n" ),
+ );
+ }
+
+ /**
+ * Tests that backslash-EOF in an ident produces U+FFFD in the value.
+ *
+ * @ticket 65738
+ */
+ public function test_ident_backslash_eof(): void {
+ $processor = new WP_CSS_Token_Processor( 'abc\\' );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $processor->get_token_type() );
+ $this->assertSame( "abc\u{FFFD}", $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Bad string tokens have no associated value.
+ *
+ * @ticket 65738
+ */
+ public function test_bad_string_token_value_is_null(): void {
+ $processor = new WP_CSS_Token_Processor( "'str\ning'" );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_BAD_STRING, $processor->get_token_type() );
+ $this->assertNull( $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Tests that WP_CSS_Token::is_terminated() reports whether the last token ended at its closing delimiter.
+ *
+ * @ticket 65738
+ * @dataProvider data_is_terminated
+ */
+ public function test_is_terminated( string $css, string $expected_type, bool $expected ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertNull( $processor->get_token(), 'Before the first token there is nothing to report.' );
+
+ $type = null;
+ while ( $processor->next_token() ) {
+ $type = $processor->get_token_type();
+ $terminated = $processor->get_token()->is_terminated();
+ }
+
+ $this->assertSame( $expected_type, $type, 'The last token type did not match.' );
+ $this->assertSame( $expected, $terminated, 'The last token termination flag did not match.' );
+ }
+
+ /**
+ * Data provider for test_is_terminated().
+ *
+ * @return array[]
+ */
+ public static function data_is_terminated(): array {
+ return array(
+ 'comment terminated' => array( '/* a */', WP_CSS_Token_Processor::TOKEN_COMMENT, true ),
+ 'comment empty terminated' => array( '/**/', WP_CSS_Token_Processor::TOKEN_COMMENT, true ),
+ 'comment unterminated' => array( '/* a', WP_CSS_Token_Processor::TOKEN_COMMENT, false ),
+ 'comment "/*/" unterminated' => array( '/*/', WP_CSS_Token_Processor::TOKEN_COMMENT, false ),
+ 'comment terminated at end of input' => array( 'a /**/', WP_CSS_Token_Processor::TOKEN_COMMENT, true ),
+ 'string terminated' => array( '"a"', WP_CSS_Token_Processor::TOKEN_STRING, true ),
+ 'string escaped backslash' => array( '"a\\\\"', WP_CSS_Token_Processor::TOKEN_STRING, true ),
+ 'string unterminated' => array( '"a', WP_CSS_Token_Processor::TOKEN_STRING, false ),
+ 'string escape consumed the quote' => array( '"a\\"', WP_CSS_Token_Processor::TOKEN_STRING, false ),
+ 'string lone quote' => array( '"', WP_CSS_Token_Processor::TOKEN_STRING, false ),
+ 'string single quoted terminated' => array( "a 'b'", WP_CSS_Token_Processor::TOKEN_STRING, true ),
+ 'string single quoted unterminated' => array( "a 'b", WP_CSS_Token_Processor::TOKEN_STRING, false ),
+ 'url terminated' => array( 'url(a)', WP_CSS_Token_Processor::TOKEN_URL, true ),
+ 'url escaped backslash' => array( 'url(a\\\\)', WP_CSS_Token_Processor::TOKEN_URL, true ),
+ 'url unterminated' => array( 'url(a', WP_CSS_Token_Processor::TOKEN_URL, false ),
+ 'url escape consumed the paren' => array( 'url(a\\)', WP_CSS_Token_Processor::TOKEN_URL, false ),
+ 'url trailing whitespace' => array( 'url(a ', WP_CSS_Token_Processor::TOKEN_URL, false ),
+ 'url whitespace then paren' => array( 'url(a )', WP_CSS_Token_Processor::TOKEN_URL, true ),
+ 'url empty unterminated' => array( 'url(', WP_CSS_Token_Processor::TOKEN_URL, false ),
+ 'bad url terminated' => array( 'url(a b)', WP_CSS_Token_Processor::TOKEN_BAD_URL, true ),
+ 'bad url unterminated' => array( 'url(a b', WP_CSS_Token_Processor::TOKEN_BAD_URL, false ),
+ 'ident at end of input' => array( 'color: red', WP_CSS_Token_Processor::TOKEN_IDENT, true ),
+ 'ident backslash at end of input' => array( 'abc\\', WP_CSS_Token_Processor::TOKEN_IDENT, true ),
+ 'function at end of input' => array( 'calc(', WP_CSS_Token_Processor::TOKEN_FUNCTION, true ),
+ );
+ }
+
+ /**
+ * Tests that WP_CSS_Token::is_terminated() is reset for the token after an unterminated one.
+ *
+ * @ticket 65738
+ */
+ public function test_is_terminated_resets_between_tokens(): void {
+ $processor = new WP_CSS_Token_Processor( "'a\nb" );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_BAD_STRING, $processor->get_token_type() );
+ $this->assertTrue( $processor->get_token()->is_terminated() );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_WHITESPACE, $processor->get_token_type() );
+ $this->assertTrue( $processor->get_token()->is_terminated() );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $processor->get_token_type() );
+ $this->assertTrue( $processor->get_token()->is_terminated() );
+ }
+
+ /**
+ * Tests that hash tokens expose the proper type flag.
+ *
+ * @ticket 65738
+ * @dataProvider data_hash_tokens_expose_type_flags
+ */
+ public function test_hash_tokens_expose_type_flags( string $css, array $expected_tokens ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ foreach ( $expected_tokens as $expected_token ) {
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( $expected_token['type'], $processor->get_token_type() );
+ $this->assertSame( $expected_token['raw'], substr( $css, $processor->get_token_start(), $processor->get_token_length() ) );
+ $this->assertSame( $expected_token['type_flag'], $processor->get_token()->get_flag() );
+ }
+
+ $this->assertFalse( $processor->next_token() );
+ $this->assertNull( $processor->get_token() );
+ }
+
+ /**
+ * Data provider for test_hash_tokens_expose_type_flags().
+ *
+ * @return array[]
+ */
+ public static function data_hash_tokens_expose_type_flags(): array {
+ return array(
+ 'id hash' => array(
+ '#id',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#id',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_ID,
+ ),
+ ),
+ ),
+ 'unrestricted hash starting digit' => array(
+ '#1id',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#1id',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED,
+ ),
+ ),
+ ),
+ 'id hash starting hyphen ident' => array(
+ '#-id',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#-id',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_ID,
+ ),
+ ),
+ ),
+ 'unrestricted hash starting hyphen' => array(
+ '#-1id',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#-1id',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED,
+ ),
+ ),
+ ),
+ 'id hash starting escape' => array(
+ '#\\@special',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#\\@special',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_ID,
+ ),
+ ),
+ ),
+ 'hash delimiter has no type flag' => array(
+ '#',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '#',
+ 'type_flag' => null,
+ ),
+ ),
+ ),
+ 'following token clears type flag' => array(
+ '#id .',
+ array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#id',
+ 'type_flag' => WP_CSS_Token_Processor::HASH_TOKEN_ID,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'type_flag' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'type_flag' => null,
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Tests token type flags for numeric and non-numeric tokens.
+ *
+ * @dataProvider data_token_type_flag
+ */
+ public function test_token_type_flag( string $css, ?string $expected_type ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( $expected_type, $processor->get_token()->get_flag() );
+ }
+
+ /**
+ * Data provider for test_token_type_flag().
+ *
+ * @return array[]
+ */
+ public static function data_token_type_flag(): array {
+ return array(
+ 'integer' => array( '42', 'integer' ),
+ 'positive integer' => array( '+42', 'integer' ),
+ 'negative integer' => array( '-42', 'integer' ),
+ 'zero' => array( '0', 'integer' ),
+ 'decimal' => array( '42.0', 'number' ),
+ 'decimal with fraction' => array( '42.5', 'number' ),
+ 'leading decimal point' => array( '.5', 'number' ),
+ 'exponent lowercase' => array( '1e2', 'number' ),
+ 'exponent uppercase' => array( '1E2', 'number' ),
+ 'exponent with plus' => array( '1E+2', 'number' ),
+ 'exponent with minus' => array( '1e-2', 'number' ),
+ 'dimension integer' => array( '10px', 'integer' ),
+ 'dimension decimal' => array( '10.5px', 'number' ),
+ 'dimension exponent' => array( '1e2px', 'number' ),
+ 'percentage integer' => array( '20%', null ),
+ 'percentage decimal' => array( '20.0%', null ),
+ 'ident token' => array( 'red', null ),
+ 'string token' => array( '"hello"', null ),
+ );
+ }
+
+ /**
+ * Tests handling of non-UTF-8 byte sequences in identifiers.
+ *
+ * Invalid UTF-8 sequences should be replaced with U+FFFD replacement characters
+ * during tokenization, allowing the CSS to continue processing.
+ */
+ public function test_non_utf8_sequences_in_identifiers(): void {
+ // Invalid UTF-8 sequence 0xC0 0x80 (overlong encoding).
+ $css = ".class\xF1name";
+
+ $expected = array(
+ // .class�name (0xF1 replaced with U+FFFD).
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => "class\xF1name",
+ 'value' => 'class�name',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value', 'unit' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ public function test_invalid_utf8_in_normal_segment_combined_with_escape(): void {
+ $css = ".test\xF1\\41name";
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => "test\xF1\\41name",
+ 'value' => "test\u{FFFD}Aname",
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ public function test_invalid_utf8_as_escaped_character(): void {
+ $css = ".a\\\xF1b";
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => "a\\\xF1b",
+ 'value' => "a\u{FFFD}b",
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ public function test_invalid_utf8_with_valid_prefix_in_identifiers(): void {
+ // Invalid 2-byte prefix is replaced with a single U+FFFD.
+ $css = ".test\xE2\x80name";
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => "test\xE2\x80name",
+ 'value' => 'test�name',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ public function test_invalid_utf8_with_two_single_byte_invalid_sequences(): void {
+ // Two distinct single byte invalid sequences are replaced with
+ // two separate U+FFFD replacement characters.
+ $css = ".test\xE2\xE2name";
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => "test\xE2\xE2name",
+ 'value' => 'test��name',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ /**
+ * Legacy test to ensure basic tokenization still works.
+ */
+ public function test_tokenize_labels_core_tokens(): void {
+ $css = << WP_CSS_Token_Processor::TOKEN_AT_KEYWORD,
+ 'raw' => '@media',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'screen',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'and',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_PAREN,
+ 'raw' => '(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'min-width',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '10px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\n\t",
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'url(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"/images/a.png"',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\n",
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of complex selectors with pseudo-classes.
+ */
+ public function test_complex_selector_with_pseudo_classes(): void {
+ $css = 'a:hover::before, div.class#id:not(.disabled)';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'a',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'hover',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'before',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'div',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'class',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#id',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'not(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'disabled',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of CSS comments.
+ */
+ public function test_css_comments(): void {
+ $css = '/* This is a comment */ .class { color: red; /* Another comment */ }';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMENT,
+ 'raw' => '/* This is a comment */',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'class',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'red',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMENT,
+ 'raw' => '/* Another comment */',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of media queries.
+ */
+ public function test_media_query(): void {
+ $css = '@media screen and (min-width: 768px) and (max-width: 1024px)';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_AT_KEYWORD,
+ 'raw' => '@media',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'screen',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'and',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_PAREN,
+ 'raw' => '(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'min-width',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '768px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'and',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_PAREN,
+ 'raw' => '(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'max-width',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '1024px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of keyframes animation.
+ */
+ public function test_keyframes_animation(): void {
+ $css = '@keyframes slide-in { 0% { opacity: 0; } 100% { opacity: 1; } }';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_AT_KEYWORD,
+ 'raw' => '@keyframes',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'slide-in',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '0%',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'opacity',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '100%',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'opacity',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '1',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of vendor-prefixed properties.
+ */
+ public function test_vendor_prefixed_properties(): void {
+ $css = '-webkit-transform: rotate(45deg); -moz-border-radius: 5px;';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '-webkit-transform',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'rotate(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '45deg',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '-moz-border-radius',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '5px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of attribute selectors.
+ */
+ public function test_attribute_selectors(): void {
+ $css = 'input[type="text"][required], a[href^="https://"]';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'input',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET,
+ 'raw' => '[',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'type',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '=',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"text"',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET,
+ 'raw' => ']',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET,
+ 'raw' => '[',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'required',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET,
+ 'raw' => ']',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'a',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET,
+ 'raw' => '[',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'href',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '^',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '=',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"https://"',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET,
+ 'raw' => ']',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of calc() function with complex expressions.
+ */
+ public function test_calc_function(): void {
+ $css = 'width: calc(100% - 20px * 2 + 5em);';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'width',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'calc(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '100%',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '-',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '20px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '*',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '2',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '+',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '5em',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of RGB/RGBA color functions.
+ */
+ public function test_color_functions(): void {
+ $css = 'color: rgb(255, 128, 0); background: rgba(0, 0, 0, 0.5);';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'rgb(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '255',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '128',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'rgba(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '0.5',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of CSS custom properties (variables).
+ */
+ public function test_css_variables(): void {
+ $css = '--main-color: #ff0000; color: var(--main-color);';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '--main-color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#ff0000',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'var(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '--main-color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of gradient functions.
+ */
+ public function test_gradient_functions(): void {
+ $css = 'background: linear-gradient(to right, red 0%, blue 100%);';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'linear-gradient(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'to',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'right',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'red',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '0%',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'blue',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '100%',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of grid layout properties.
+ */
+ public function test_grid_layout(): void {
+ $css = 'grid-template-columns: repeat(3, 1fr); gap: 10px 20px;';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'grid-template-columns',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'repeat(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_NUMBER,
+ 'raw' => '3',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '1fr',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'gap',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '10px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '20px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of URL functions with various formats.
+ */
+ public function test_url_formats(): void {
+ $css = 'background: url("image.png"), url(\'font.woff\'), url(https://example.com/bg.jpg);';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'url(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"image.png"',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => 'url(',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => "'font.woff'",
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_URL,
+ 'raw' => 'url(https://example.com/bg.jpg)',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of !important declarations.
+ */
+ public function test_important_declarations(): void {
+ $css = 'color: red !important; margin: 0px !important;';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'red',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '!',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'important',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'margin',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '0px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '!',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'important',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of multiple selectors with combinators.
+ */
+ public function test_complex_combinators(): void {
+ $css = 'div > p + span ~ a.link';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'div',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '>',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'p',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '+',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'span',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '~',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'a',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'link',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests tokenization of escaped characters in identifiers.
+ */
+ public function test_escaped_identifiers(): void {
+ $css = '.class\\:name, #id\\@special { color: blue; }';
+
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'class\\:name',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMA,
+ 'raw' => ',',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#id\\@special',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'blue',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw' ) );
+ $this->assertSame( $actual_tokens, $expected );
+ }
+
+ /**
+ * Tests that token values are decoded and normalized.
+ *
+ * Uses a comprehensive CSS selector with rules that includes:
+ * - CSS escapes in class names and IDs
+ * - URLs with escape sequences
+ * - String values with escapes and line endings
+ * - Comments with various line ending characters
+ * - Null bytes in identifiers
+ * - Mixed line endings (\r\n, \r, \f) that need normalization
+ */
+ public function test_token_values_are_decoded_and_normalized(): void {
+ // Comprehensive CSS with normalization requirements.
+ $css = "/* Comment\r\nwith\flines */\r\n" .
+ ".c\\6c ass.n\\61 me\r#id\\@value\r\n{\r\n" .
+ "\tbackground:\furl(path\\2f to\\2f image.png);\r\n" .
+ "\tcontent:\r\"text\\A string\";\r\n" .
+ '}';
+
+ $expected = array(
+ // Comment with \r\n and \f.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COMMENT,
+ 'raw' => "/* Comment\r\nwith\flines */",
+ 'value' => " Comment\nwith\nlines ",
+ ),
+ // Whitespace with \r\n.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r\n",
+ 'value' => null,
+ ),
+ // Class selector delimiter.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ // Class name with escape (\6c = 'l'), space gets consumed by escape.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'c\\6c ass',
+ 'value' => 'class', // Decoded: \6c → l, space consumed.
+ ),
+ // Delimiter.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ // Identifier with escape (\61 = 'a'), space gets consumed.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'n\\61 me',
+ 'value' => 'name', // Decoded: \61 → a, space consumed.
+ ),
+ // Whitespace with \r.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r",
+ 'value' => null,
+ ),
+ // ID selector with escape.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => '#id\\@value',
+ 'value' => 'id@value', // Decoded value.
+ ),
+ // Whitespace with \r\n.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r\n",
+ 'value' => null,
+ ),
+ // Opening brace.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ 'value' => null,
+ ),
+ // Whitespace with \r\n and tab (consumed together).
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r\n\t",
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ 'value' => 'background',
+ ),
+ // Colon.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ // Whitespace with \f.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\f",
+ 'value' => null,
+ ),
+ // URL token with escapes (entire url(...) is one token).
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_URL,
+ 'raw' => 'url(path\\2f to\\2f image.png)',
+ 'value' => 'path/to/image.png', // Decoded: \2f → /, spaces consumed.
+ ),
+ // Semicolon.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ // Whitespace with \r\n and tab (consumed together).
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r\n\t",
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'content',
+ 'value' => 'content',
+ ),
+ // Colon.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ // Whitespace with \r.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r",
+ 'value' => null,
+ ),
+ // String with escape (\A = newline, space consumed).
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"text\\A string"',
+ 'value' => "text\nstring", // \A → \n, space consumed.
+ ),
+ // Semicolon.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ // Whitespace with \r\n.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => "\r\n",
+ 'value' => null,
+ ),
+ // Closing brace.
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ 'value' => null,
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ /**
+ * Tests that a comment value keeps its escapes.
+ *
+ * @ticket 65738
+ * @dataProvider data_comment_value
+ */
+ public function test_comment_value_keeps_escapes( string $css, string $expected ): void {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_COMMENT, $processor->get_token_type() );
+
+ $this->assertSame( $expected, $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Data provider for test_comment_value_keeps_escapes().
+ *
+ * @return array[]
+ */
+ public static function data_comment_value(): array {
+ return array(
+ 'escape' => array( '/* \\41 */', ' \\41 ' ),
+ 'newlines preprocessed' => array( "/*\\41\r\n\f*/", "\\41\n\n" ),
+ );
+ }
+
+ public function test_dimension_token_value(): void {
+ $css = '10px;15em;20%;30pt;40pc;50vw;';
+ $expected = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '10px',
+ 'value' => '10',
+ 'unit' => 'px',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '15em',
+ 'value' => '15',
+ 'unit' => 'em',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_PERCENTAGE,
+ 'raw' => '20%',
+ 'value' => '20',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '30pt',
+ 'value' => '30',
+ 'unit' => 'pt',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '40pc',
+ 'value' => '40',
+ 'unit' => 'pc',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '50vw',
+ 'value' => '50',
+ 'unit' => 'vw',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ );
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value', 'unit' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ /**
+ * Tests escape sequences in unusual and edge-case positions.
+ *
+ * Covers:
+ * - Multiple consecutive escapes
+ * - Escapes in function names
+ * - Escapes in at-keywords
+ * - Escapes in dimension units
+ * - Null byte escapes (\0)
+ * - Escaped special characters (@, #, !, etc.)
+ * - Escaped whitespace that gets consumed by the escape
+ * - Unicode escapes for various characters
+ */
+ public function test_escape_sequences_in_unusual_places() {
+ // Complex CSS with escapes in many unusual but valid positions
+ $css = '@\\6D edia ' . // @media with \6D (m) and space consumed
+ '\\73 creen ' . // screen with \73 (s) and space consumed
+ '{' .
+ ' .\\63 l\\61 ss\\5F name ' . // .class_name with escapes and spaces consumed
+ "#\\69 d\\5C 0test\x00 " . // #id\0test with null byte escape (should be preserved)
+ // AND an actual null byte (should be replaced with a U+FFFD REPLACEMENT CHARACTER)
+ '{' .
+ ' c\\6F lor: ' . // color: with \6F (o) and space consumed
+ 'r\\65 d ' . // red with escape
+ '\\21 important;' . // !important with escaped !
+ ' w\\69 dth: ' . // width:
+ '10\\70 x;' . // 10px (dimension with escaped unit)
+ ' background: ' .
+ '\\75 rl(' . // url( with escaped u
+ '"p\\61 th\\2F img\\2E png"' . // "path/img.png" with escapes
+ ');' .
+ ' content: "\\5C \\5C ";' . // "\\ \\" - escaped backslashes
+ ' font-family: \\22 Arial\\22 ;' . // "Arial" with escaped quotes
+ ' }' .
+ '}';
+
+ $expected = array(
+ // @\6D edia -> @media
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_AT_KEYWORD,
+ 'raw' => '@\\6D edia',
+ 'value' => 'media',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // \73 creen -> screen
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '\\73 creen',
+ 'value' => 'screen',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // Delimiter .
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '.',
+ 'value' => '.',
+ ),
+ // \63 l\61 ss\5F name -> class_name
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '\\63 l\\61 ss\\5F name',
+ 'value' => 'class_name',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // #\69 d\5C 0test -> #id\0test (with encoded null byte)
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_HASH,
+ 'raw' => "#\\69 d\\5C 0test\x00",
+ // Ensure the value is normalized.
+ 'value' => "id\\0test�",
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_LEFT_BRACE,
+ 'raw' => '{',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // c\6F lor -> color
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'c\\6F lor',
+ 'value' => 'color',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // r\65 d -> red
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'r\\65 d',
+ 'value' => 'red',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // \21 important -> !important (single identifier with escaped !)
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '\\21 important',
+ 'value' => '!important',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // w\69 dth -> width
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'w\\69 dth',
+ 'value' => 'width',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // 10\70 x -> 10px (dimension with escaped unit)
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DIMENSION,
+ 'raw' => '10\\70 x',
+ 'value' => '10',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'background',
+ 'value' => 'background',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // \75 rl( -> url( (escaped function name)
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_FUNCTION,
+ 'raw' => '\\75 rl(',
+ 'value' => 'url',
+ ),
+ // String with escapes: "p\61 th\2F img\2E png"
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"p\\61 th\\2F img\\2E png"',
+ 'value' => 'path/img.png',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN,
+ 'raw' => ')',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'content',
+ 'value' => 'content',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // String with escaped backslashes: "\5C \5C " -> "\\"
+ // Each \5C sequence (with trailing space consumed) becomes one backslash
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_STRING,
+ 'raw' => '"\\5C \\5C "',
+ 'value' => '\\\\', // Two backslashes total
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => 'font-family',
+ 'value' => 'font-family',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_COLON,
+ 'raw' => ':',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ // \22 Arial\22 -> "Arial" (escaped quotes make it an ident)
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_IDENT,
+ 'raw' => '\\22 Arial\\22 ',
+ 'value' => '"Arial"',
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_SEMICOLON,
+ 'raw' => ';',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_WHITESPACE,
+ 'raw' => ' ',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ 'value' => null,
+ ),
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE,
+ 'raw' => '}',
+ 'value' => null,
+ ),
+ );
+
+ $actual_tokens = $this->collect_tokens( $css, array( 'type', 'raw', 'value' ) );
+ $this->assertSame( $expected, $actual_tokens );
+ }
+
+ /**
+ * Tests that decode_range() respects the token's length boundary.
+ *
+ * The escape sequence \41 (= "A") triggers the slow path. The CSS after
+ * the closing quote must not appear in the token value.
+ */
+ public function test_decode_range_respects_length_boundary(): void {
+ $processor = new WP_CSS_Token_Processor( '"hello\\41 world"; color: red;' );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() );
+ $this->assertSame( 'helloAworld', $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Tests that decode_escape_at() consumes at most six hex digits.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
+ */
+ public function test_decode_escape_at_hex_limit_is_six_digits(): void {
+ $processor = new WP_CSS_Token_Processor( '"\\0000411rest"' );
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() );
+ $this->assertSame( 'A1rest', $processor->get_token()->get_value() );
+ }
+
+ /**
+ * Test bounds check when consuming and ident start token.
+ */
+ public function test_ident_start_codepoint_bounds_check(): void {
+ $actual_tokens = $this->collect_tokens( '-', array( 'type', 'raw' ) );
+ $expected_tokens = array(
+ array(
+ 'type' => WP_CSS_Token_Processor::TOKEN_DELIM,
+ 'raw' => '-',
+ ),
+ );
+ $this->assertSame( $expected_tokens, $actual_tokens );
+ }
+}
diff --git a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php
new file mode 100644
index 0000000000000..07259163a8475
--- /dev/null
+++ b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php
@@ -0,0 +1,585 @@
+assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' );
+ }
+
+ /**
+ * Data provider for test_string_escaping().
+ *
+ * @return array[]
+ */
+ public static function data_string_escaping(): array {
+ return array(
+ // Passthrough — no escaping needed, only quoting.
+ 'empty string' => array( "''", '""' ),
+ 'simple ASCII' => array( "'Arial'", '"Arial"' ),
+ 'spaces preserved' => array( "'Exo 2'", '"Exo 2"' ),
+ 'leading/trailing spaces' => array( "' Arial '", '" Arial "' ),
+ 'whitespace-only' => array( "' '", '" "' ),
+ 'numbers pass through' => array( "'12345'", '"12345"' ),
+ 'non-ASCII passthrough' => array( "'café'", '"café"' ),
+
+ // Backslash escaping — must happen first to prevent double-escaping.
+ 'backslash' => array( '"Back\\\\Slash"', '"Back\\5c Slash"' ),
+ 'double backslash' => array( '"\\\\\\\\"', '"\\5c \\5c "' ),
+ 'backslash before quote' => array( '"a\\\\\'b"', '"a\\5c \\27 b"' ),
+
+ // NULL → U+FFFD replacement character.
+ 'null byte' => array( "\"a\x00b\"", '"a�b"' ),
+ 'zero escape' => array( '"a\\0 b"', '"a�b"' ),
+
+ // Control characters are escaped as code points; CR and FF are not normalized to LF.
+ 'LF' => array( '"a\\A b"', '"a\\a b"' ),
+ 'CR' => array( '"a\\D b"', '"a\\d b"' ),
+ 'CRLF' => array( '"a\\D \\A b"', '"a\\d \\a b"' ),
+ 'form feed' => array( '"a\\C b"', '"a\\c b"' ),
+ 'tab' => array( "\"a\tb\"", '"a\\9 b"' ),
+ 'escaped tab' => array( '"a\\9 b"', '"a\\9 b"' ),
+ 'U+0001' => array( "\"a\x01b\"", '"a\\1 b"' ),
+ 'U+001F' => array( '"a\\1F b"', '"a\\1f b"' ),
+ 'delete' => array( "\"a\x7Fb\"", '"a\\7f b"' ),
+ 'U+0080 is not escaped' => array( '"a\\80 b"', "\"a\u{80}b\"" ),
+
+ // HTML-problematic characters.
+ 'HTML characters < > &' => array( '"ac&d"', '"a\\3c b\\3e c\\26 d"' ),
+
+ // CSS-problematic characters.
+ 'CSS syntax , ; { }' => array( '"a,b;c{d}"', '"a\\2c b\\3b c\\7b d\\7d "' ),
+ 'single quote' => array( '"CSS\'s strings"', '"CSS\\27 s strings"' ),
+ 'double quote' => array( '\'Say "Hi"\'', '"Say \\22 Hi\\22 "' ),
+ );
+ }
+
+ /**
+ * Tests the example in the serialize() docblock.
+ *
+ * @ticket 65738
+ *
+ * @covers ::serialize
+ */
+ public function test_docblock_example(): void {
+ $this->assertSame(
+ 'url(a\\26 b) calc(1px + 2px)',
+ WP_CSS_Token_Serializer::serialize( self::tokenize( 'url( a&b ) calc(1px + 2px' ) )
+ );
+ }
+
+ /**
+ * Tests WP_CSS_Token_Serializer::escape_ident() escapes CSS identifiers.
+ *
+ * @ticket 65738
+ *
+ * @dataProvider data_ident
+ *
+ * @covers ::escape_ident
+ */
+ public function test_ident( string $input, string $expected ): void {
+ $serialized = WP_CSS_Token_Serializer::escape_ident( $input );
+ $this->assertSame( $expected, $serialized );
+
+ $processor = new WP_CSS_Token_Processor( $serialized );
+ if ( '' === $input ) {
+ $this->assertFalse( $processor->next_token() );
+ return;
+ }
+
+ $this->assertTrue( $processor->next_token() );
+ $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $processor->get_token_type() );
+
+ $this->assertSame( str_replace( "\0", '�', $input ), $processor->get_token()->get_value() );
+ $this->assertFalse( $processor->next_token() );
+ }
+
+ /**
+ * Data provider for test_ident().
+ */
+ public static function data_ident(): Generator {
+ yield 'Empty identifier' => array( '', '' );
+
+ // Simple idents — no escaping needed.
+ yield 'Simple alpha ident' => array( 'serif', 'serif' );
+ yield 'Hyphenated ident' => array( 'sans-serif', 'sans-serif' );
+ yield 'Underscore prefix' => array( '_foo', '_foo' );
+ yield 'Single char' => array( 'a', 'a' );
+ yield 'Single hyphen' => array( '-', '\\2d ' );
+ yield 'Custom property prefix' => array( '--custom', '--custom' );
+ yield 'Non-ASCII' => array( 'café', 'café' );
+ yield 'Middle dot' => array( "a\u{B7}b", "a\u{B7}b" );
+ yield 'Zero width joiner' => array( "a\u{200D}b", "a\u{200D}b" );
+ yield 'Replacement character' => array( "a\u{FFFD}b", "a\u{FFFD}b" );
+ yield 'Supplementary plane' => array( "a\u{1F600}b", "a\u{1F600}b" );
+
+ // Non-ASCII code points outside the identifier code points — must be escaped.
+ yield 'Leading C1 control' => array( "\u{80}a", '\\80 a' );
+ yield 'No-break space within' => array( "a\u{A0}b", 'a\\a0 b' );
+ yield 'Multiplication sign within' => array( "a\u{D7}b", 'a\\d7 b' );
+ yield 'Greek question mark within' => array( "a\u{37E}b", 'a\\37e b' );
+ yield 'Zero width space within' => array( "a\u{200B}b", 'a\\200b b' );
+ yield 'U+FFFE within' => array( "a\u{FFFE}b", 'a\\fffe b' );
+
+ // Invalid ident starts — must be escaped.
+ yield 'Leading digits' => array( '123', '\\31 23' );
+ yield 'Leading digit with alpha' => array( '5foo', '\\35 foo' );
+ yield 'Hyphen then digit' => array( '-5px', '-\\35 px' );
+ yield 'Leading space' => array( ' leading-space', '\\20 leading-space' );
+ yield 'Leading tab' => array( "\tleading-tab", '\\9 leading-tab' );
+ yield 'Leading control character' => array( "\x01foo", '\\1 foo' );
+
+ // Whitespace within ident.
+ yield 'Space within' => array( 'My Font', 'My\\20 Font' );
+ yield 'Multiple spaces within' => array( 'a b c', 'a\\20 b\\20 c' );
+ yield 'Tab within' => array( "has\ttab", 'has\\9 tab' );
+ yield 'Newline within' => array( "has\nnewline", 'has\\a newline' );
+ yield 'Carriage return within' => array( "has\rreturn", 'has\\d return' );
+ yield 'NULL byte within' => array( "has\0null", 'has�null' );
+ yield 'DEL within' => array( "has\x7Fdelete", 'has\\7f delete' );
+
+ // Special characters.
+ yield 'Apostrophe' => array( "Font's", 'Font\\27 s' );
+ yield 'Angle brackets' => array( '', '\\3c html\\3e ' );
+ yield 'Comma' => array( 'a,b', 'a\\2c b' );
+ yield 'Semicolon' => array( 'a;b', 'a\\3b b' );
+ yield 'Backslash' => array( 'a\\b', 'a\\5c b' );
+ }
+
+ /**
+ * Tests that serialize() writes an empty CSS comment between the tokens of a marked pair.
+ *
+ * @ticket 65738
+ * @dataProvider data_marked_pairs
+ *
+ * @covers ::serialize
+ */
+ public function test_marked_pair_is_separated_by_an_empty_comment( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' );
+ }
+
+ /**
+ * Data provider for test_marked_pair_is_separated_by_an_empty_comment().
+ *
+ * One case for each ✗ cell in the table in CSS Syntax Level 3 §9.
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#serialization
+ *
+ * @return Generator
+ */
+ public static function data_marked_pairs(): Generator {
+ // Source text for the first token, keyed by its row heading.
+ $rows = array(
+ 'ident' => 'a',
+ 'at-keyword' => '@a',
+ 'hash' => '#a',
+ 'dimension' => '1px',
+ '#' => '#',
+ '-' => '-',
+ 'number' => '1',
+ '@' => '@',
+ '.' => '.',
+ '+' => '+',
+ '/' => '/',
+ );
+
+ // Source text and its serialization for the second token, keyed by its column heading.
+ $columns = array(
+ 'ident' => array( 'b', 'b' ),
+ 'function' => array( 'b()', 'b()' ),
+ 'url' => array( 'url(b)', 'url(b)' ),
+ 'bad url' => array( 'url(b c)', 'url(()' ),
+ '-' => array( '-', '-' ),
+ 'number' => array( '2', '2' ),
+ 'percentage' => array( '2%', '2%' ),
+ 'dimension' => array( '2px', '2px' ),
+ 'CDC' => array( '-->', '-->' ),
+ '(' => array( '()', '()' ),
+ '*' => array( '*', '*' ),
+ '%' => array( '%', '%' ),
+ );
+
+ $marked = array(
+ 'ident' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC', '(' ),
+ 'at-keyword' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ),
+ 'hash' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ),
+ 'dimension' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ),
+ '#' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ),
+ '-' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ),
+ 'number' => array( 'ident', 'function', 'url', 'bad url', 'number', 'percentage', 'dimension', 'CDC', '%' ),
+ '@' => array( 'ident', 'function', 'url', 'bad url', '-', 'CDC' ),
+ '.' => array( 'number', 'percentage', 'dimension' ),
+ '+' => array( 'number', 'percentage', 'dimension' ),
+ '/' => array( '*' ),
+ );
+
+ foreach ( $marked as $row => $row_columns ) {
+ foreach ( $row_columns as $column ) {
+ list( $column_css, $column_serialized ) = $columns[ $column ];
+ yield "{$row} followed by {$column}" => array(
+ "{$rows[ $row ]}/**/{$column_css}",
+ "{$rows[ $row ]}/**/{$column_serialized}",
+ );
+ }
+ }
+ }
+
+ /**
+ * Tests that serialize() writes no comment between the tokens of an unmarked pair.
+ *
+ * @ticket 65738
+ * @dataProvider data_unmarked_pairs
+ *
+ * @covers ::serialize
+ */
+ public function test_unmarked_pair_is_not_separated( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' );
+ }
+
+ /**
+ * Data provider for test_unmarked_pair_is_not_separated().
+ *
+ * @return array[]
+ */
+ public static function data_unmarked_pairs(): array {
+ return array(
+ 'ident followed by *' => array( 'a/**/*', 'a*' ),
+ 'ident followed by %' => array( 'a/**/%', 'a%' ),
+ 'ident followed by colon' => array( 'a/**/:', 'a:' ),
+ 'at-keyword followed by (' => array( '@a/**/()', '@a()' ),
+ 'hash followed by *' => array( '#a/**/*', '#a*' ),
+ 'dimension followed by %' => array( '1px/**/%', '1px%' ),
+ '# followed by %' => array( '#/**/%', '#%' ),
+ '- followed by *' => array( '-/**/*', '-*' ),
+ 'number followed by -' => array( '1/**/-', '1-' ),
+ 'number followed by (' => array( '1/**/()', '1()' ),
+ '@ followed by number' => array( '@/**/1', '@1' ),
+ '. followed by ident' => array( './**/a', '.a' ),
+ '+ followed by ident' => array( '+/**/a', '+a' ),
+ '/ followed by ident' => array( '//**/a', '/a' ),
+ 'string followed by ident' => array( '"a"/**/b', '"a"b' ),
+ 'url followed by ident' => array( 'url(a)/**/b', 'url(a)b' ),
+ 'function followed by ident' => array( 'a(/**/b)', 'a(b)' ),
+ ') followed by ident' => array( '(a)/**/b', '(a)b' ),
+ 'CDC followed by ident' => array( '-->/**/a', '-->a' ),
+ 'percentage followed by ident' => array( '1%/**/a', '1%a' ),
+ );
+ }
+
+ /**
+ * Tests that serialize() drops comments and writes each whitespace run as one space.
+ *
+ * @ticket 65738
+ * @dataProvider data_whitespace_and_comments
+ *
+ * @covers ::serialize
+ */
+ public function test_whitespace_and_comments( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ }
+
+ /**
+ * Data provider for test_whitespace_and_comments().
+ *
+ * @return array[]
+ */
+ public static function data_whitespace_and_comments(): array {
+ return array(
+ 'empty input' => array( '', '' ),
+ 'one space' => array( 'a b', 'a b' ),
+ 'mixed whitespace run' => array( "a \t\r\n\f b", 'a b' ),
+ 'run broken by a comment' => array( 'a /* x */ b', 'a b' ),
+ 'whitespace then a comment' => array( 'a /**/b', 'a b' ),
+ 'a comment then whitespace' => array( 'a/**/ b', 'a b' ),
+ 'leading whitespace' => array( "\n a", ' a' ),
+ 'trailing whitespace' => array( "a \n", 'a ' ),
+ 'only whitespace' => array( "\t\n", ' ' ),
+ 'only a comment' => array( '/* x */', '' ),
+ 'comment in unmarked pair' => array( 'a/* x */,b', 'a,b' ),
+ 'unterminated comment' => array( 'a /* x', 'a ' ),
+ );
+ }
+
+ /**
+ * Tests how serialize() writes each token type.
+ *
+ * @ticket 65738
+ * @dataProvider data_token_serialization
+ *
+ * @covers ::serialize
+ */
+ public function test_token_serialization( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' );
+ }
+
+ /**
+ * Data provider for test_token_serialization().
+ *
+ * @return array[]
+ */
+ public static function data_token_serialization(): array {
+ return array(
+ 'ident escapes decoded' => array( '\\66 oo', 'foo' ),
+ 'ident escapes added' => array( 'a\\:b', 'a\\3a b' ),
+ 'function' => array( '\\66 oo(1)', 'foo(1)' ),
+ 'at-keyword' => array( '@\\66 oo', '@foo' ),
+ 'hash id' => array( '#a\\:', '#a\\3a ' ),
+ 'hash id escaped digit' => array( '#\\31 a', '#\\31 a' ),
+ 'hash unrestricted' => array( '#1a', '#1a' ),
+ 'hash unrestricted escape' => array( '#1\\:', '#1\\3a ' ),
+ 'hash unrestricted hyphen' => array( '#-', '#-' ),
+ 'hash id non-ident code point' => array( '#a\\d7 b', '#a\\d7 b' ),
+ 'hash unrestricted non-ident code point' => array( '#1\\d7 b', '#1\\d7 b' ),
+ 'dimension unit non-ident code point' => array( '1\\a0 b', '1\\a0 b' ),
+ 'string' => array( "'a\"b'", '"a\\22 b"' ),
+ 'string unterminated' => array( '"abc', '"abc"' ),
+ 'bad string' => array( "'a\nb", "\"\n b" ),
+ 'url' => array( 'url( a )', 'url(a)' ),
+ 'url empty' => array( 'url()', 'url()' ),
+ 'url escaped parenthesis' => array( 'url(a\\29 b)', 'url(a\\29 b)' ),
+ 'url escaped space' => array( 'url(a\\20 b)', 'url(a\\20 b)' ),
+ 'url escaped quote' => array( 'url(a\\"b)', 'url(a\\22 b)' ),
+ 'url escaped backslash' => array( 'url(a\\\\b)', 'url(a\\5c b)' ),
+ 'url escaped control' => array( 'url(a\\1 b)', 'url(a\\1 b)' ),
+ 'url escaped newline' => array( 'url(a\\A b)', 'url(a\\a b)' ),
+ 'url ampersand' => array( 'url(a&b)', 'url(a\\26 b)' ),
+ 'url NUL' => array( "url(a\x00b)", "url(a\u{FFFD}b)" ),
+ 'url unterminated' => array( 'url(a', 'url(a)' ),
+ 'url function with a string' => array( "url( 'a')", 'url( "a")' ),
+ 'bad url' => array( 'url(a b)', 'url(()' ),
+ 'number' => array( '+.5e-3', '+.5e-3' ),
+ 'percentage' => array( '-1.5%', '-1.5%' ),
+ 'dimension' => array( '1.5e3PX', '1.5e3PX' ),
+ 'dimension unit escape' => array( '1\\2D ', '1\\2d ' ),
+ 'dimension unit read as exponent' => array( '1\\65 3', '1\\65 3' ),
+ 'dimension unit read as signed exponent' => array( '1\\45 -3', '1\\45 -3' ),
+ 'dimension unit e' => array( '1e', '1e' ),
+ 'backslash delim keeps newline' => array( "a\\\nb", "a\\\n b" ),
+ 'delim' => array( '!', '!' ),
+ 'DEL delim' => array( "a\x7Fb", "a\x7Fb" ),
+ 'CDO and CDC' => array( '', '' ),
+ 'punctuation' => array( 'a:b;c,d', 'a:b;c,d' ),
+ );
+ }
+
+ /**
+ * Tests that serialize() does not spell a CDO or CDC token from other tokens.
+ *
+ * The table in CSS Syntax Level 3 §9 compares token pairs by type. It does
+ * not cover `<`, `!` and a token that starts with `--`, which spell ``.
+ *
+ * @ticket 65738
+ * @dataProvider data_cdo_and_cdc_sequences
+ *
+ * @covers ::serialize
+ */
+ public function test_cdo_and_cdc_are_not_spelled( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' );
+ }
+
+ /**
+ * Data provider for test_cdo_and_cdc_are_not_spelled().
+ *
+ * @return array[]
+ */
+ public static function data_cdo_and_cdc_sequences(): array {
+ return array(
+ '< ! ident' => array( ' array( ' array( ' array( '', '' ),
+ 'ident -- >' => array( '--/**/>', '--/**/>' ),
+ '< space ! ident' => array( '< !/**/--x', '< !--x' ),
+ '! ident without <' => array( 'a!/**/--x', 'a!--x' ),
+ '< ! ident without --' => array( '' => array( 'a--/**/>', 'a-->' ),
+ 'ident -- space >' => array( '-- >', '-- >' ),
+ );
+ }
+
+ /**
+ * Tests that serialize() closes blocks still open at the end, in reverse order.
+ *
+ * @ticket 65738
+ * @dataProvider data_open_blocks
+ *
+ * @covers ::serialize
+ */
+ public function test_open_blocks_are_closed( string $css, string $expected ): void {
+ $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) );
+ }
+
+ /**
+ * Data provider for test_open_blocks_are_closed().
+ *
+ * @return array[]
+ */
+ public static function data_open_blocks(): array {
+ return array(
+ 'function' => array( 'calc(1px', 'calc(1px)' ),
+ 'nested blocks' => array( 'a(b[c{d', 'a(b[c{d}])' ),
+ 'closed block' => array( '(a)', '(a)' ),
+ 'mismatched closer' => array( '(]', '(])' ),
+ 'closer without a block' => array( ')', ')' ),
+ 'brace' => array( '{', '{}' ),
+ 'function with a string' => array( 'url("a"', 'url("a")' ),
+ 'inner block closed' => array( 'a((b)', 'a((b))' ),
+ );
+ }
+
+ /**
+ * Tests that the hash type flag is preserved.
+ *
+ * @ticket 65738
+ * @dataProvider data_hash_flag_round_trips
+ *
+ * @covers ::serialize
+ */
+ public function test_hash_flag_round_trips( string $css, string $expected_flag ): void {
+ $tokens = self::tokenize( $css );
+ $serialized = WP_CSS_Token_Serializer::serialize( $tokens );
+ $reparsed = self::tokenize( $serialized );
+
+ $this->assertSame( $expected_flag, $tokens[0]->get_flag(), 'The input has the wrong type flag.' );
+ $this->assertCount( 1, $reparsed, 'The hash must serialize as one token.' );
+ $this->assertSame( $tokens[0]->get_value(), $reparsed[0]->get_value(), 'The value changed.' );
+ $this->assertSame( $expected_flag, $reparsed[0]->get_flag(), 'The type flag changed.' );
+ }
+
+ /**
+ * Data provider for test_hash_flag_round_trips().
+ *
+ * @return array[]
+ */
+ public static function data_hash_flag_round_trips(): array {
+ return array(
+ 'id with an escaped digit' => array( '#\\31 a', WP_CSS_Token_Processor::HASH_TOKEN_ID ),
+ 'id' => array( '#a1', WP_CSS_Token_Processor::HASH_TOKEN_ID ),
+ 'id starting with --' => array( '#--', WP_CSS_Token_Processor::HASH_TOKEN_ID ),
+ 'unrestricted digit' => array( '#1a', WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED ),
+ 'unrestricted - and digit' => array( '#-1', WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED ),
+ 'unrestricted lone -' => array( '#-', WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED ),
+ );
+ }
+
+ /**
+ * Tests that serialize() accepts any iterable, such as a generator.
+ *
+ * @ticket 65738
+ *
+ * @covers ::serialize
+ */
+ public function test_serialize_accepts_a_generator(): void {
+ $generator = ( static function ( string $css ): Generator {
+ $processor = new WP_CSS_Token_Processor( $css );
+ while ( $processor->next_token() ) {
+ yield $processor->get_token();
+ }
+ } )( 'a /* x */ b(' );
+
+ $this->assertSame( 'a b()', WP_CSS_Token_Serializer::serialize( $generator ) );
+ }
+
+ /**
+ * Tests that serializing the corpus round-trips and is idempotent.
+ *
+ * The output must read as the input tokens, except that comments are dropped,
+ * whitespace runs are collapsed, and blocks still open are closed.
+ *
+ * @ticket 65738
+ * @dataProvider data_corpus
+ *
+ * @covers ::serialize
+ */
+ public function test_corpus_round_trips( string $css ): void {
+ $serialized = WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) );
+
+ $expected = self::significant_tokens( $css );
+ $actual = self::significant_tokens( $serialized );
+ $closers = array_splice( $actual, count( $expected ) );
+
+ $this->assertSame( $expected, $actual, 'The output must read as the same tokens.' );
+ foreach ( $closers as $closer ) {
+ $this->assertContains(
+ $closer[0],
+ array( WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ),
+ 'Only block closers may follow the input tokens.'
+ );
+ }
+ $this->assertSame( $serialized, WP_CSS_Token_Serializer::serialize( self::tokenize( $serialized ) ), 'Serialization must be idempotent.' );
+ }
+
+ /**
+ * Data provider for test_corpus_round_trips().
+ *
+ * @return array[]
+ */
+ public static function data_corpus(): array {
+ $cases = json_decode( file_get_contents( DIR_TESTDATA . '/css-api/css-test-cases.json' ), true );
+ return array_map(
+ static function ( array $test_case ): array {
+ return array( $test_case['css'] );
+ },
+ $cases
+ );
+ }
+
+ /**
+ * Tokenizes CSS.
+ *
+ * @param string $css CSS text.
+ * @return WP_CSS_Token[] Tokens.
+ */
+ private static function tokenize( string $css ): array {
+ $processor = new WP_CSS_Token_Processor( $css );
+ $tokens = array();
+ while ( $processor->next_token() ) {
+ $tokens[] = $processor->get_token();
+ }
+ return $tokens;
+ }
+
+ /**
+ * Tokenizes CSS for comparison: comments are dropped, and each run of
+ * whitespace and comments that includes whitespace is one whitespace token.
+ *
+ * @param string $css CSS text.
+ * @return array[] Type, flag, value and unit of each token.
+ */
+ private static function significant_tokens( string $css ): array {
+ $tokens = array();
+ foreach ( self::tokenize( $css ) as $token ) {
+ $type = $token->get_type();
+ if ( WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) {
+ continue;
+ }
+ if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type ) {
+ if ( array( $type ) !== end( $tokens ) ) {
+ $tokens[] = array( $type );
+ }
+ continue;
+ }
+ $tokens[] = array( $type, $token->get_flag(), $token->get_value(), $token->get_unit() );
+ }
+ return $tokens;
+ }
+}
diff --git a/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php
new file mode 100644
index 0000000000000..8eec264f5ff5c
--- /dev/null
+++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php
@@ -0,0 +1,1626 @@
+assertSame( 'color: red', $processor->get_updated_style() );
+ }
+
+ /**
+ * @ticket 65738
+ *
+ * @covers ::__construct
+ * @covers ::next_declaration
+ * @covers ::get_updated_style
+ *
+ * @dataProvider data_boolean_and_missing_style_attribute_values
+ *
+ * @param true|null $style_attribute_value Value of a boolean or missing style attribute.
+ */
+ public function test_constructor_reads_true_and_null_as_an_empty_style( $style_attribute_value ) {
+ $processor = new WP_HTML_Style_Attribute_Processor( $style_attribute_value );
+
+ $this->assertFalse( $processor->next_declaration() );
+ $this->assertSame( '', $processor->get_updated_style() );
+ }
+
+ /**
+ * @ticket 65738
+ *
+ * @covers ::__construct
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ *
+ * @dataProvider data_boolean_and_missing_style_attribute_values
+ *
+ * @param true|null $style_attribute_value Value of a boolean or missing style attribute.
+ */
+ public function test_append_declaration_to_a_style_read_from_true_or_null( $style_attribute_value ) {
+ $processor = new WP_HTML_Style_Attribute_Processor( $style_attribute_value );
+
+ $this->assertTrue( $processor->append_declaration( 'color', 'red' ) );
+ $this->assertSame( 'color: red;', $processor->get_updated_style() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_boolean_and_missing_style_attribute_values(): array {
+ return array(
+ 'missing style attribute' => array( null ),
+ 'boolean style attribute' => array( true ),
+ );
+ }
+
+ /**
+ * @ticket 65738
+ *
+ * @covers ::__construct
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ *
+ * @dataProvider data_html_with_boolean_and_missing_style_attributes
+ *
+ * @param string $html HTML with a `div` whose style attribute is boolean or missing.
+ */
+ public function test_style_from_get_attribute_on_a_boolean_or_missing_attribute_can_be_appended_to( string $html ) {
+ $tags = new WP_HTML_Tag_Processor( $html );
+ $this->assertTrue( $tags->next_tag( 'div' ) );
+
+ $styles = new WP_HTML_Style_Attribute_Processor( $tags->get_attribute( 'style' ) );
+ $this->assertTrue( $styles->append_declaration( 'color', 'red' ) );
+ $this->assertTrue( $tags->set_attribute( 'style', $styles->get_updated_style() ) );
+
+ $this->assertSame( '', $tags->get_updated_html() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_html_with_boolean_and_missing_style_attributes(): array {
+ return array(
+ 'missing style attribute' => array( '' ),
+ 'boolean style attribute' => array( '' ),
+ );
+ }
+
+ /**
+ * @covers ::__construct
+ *
+ * @dataProvider data_non_string_style_attribute_values
+ *
+ * @param mixed $style_attribute_value Value other than a string, `true` or `null`.
+ */
+ public function test_constructor_rejects_other_types( $style_attribute_value ) {
+ $this->expectException( TypeError::class );
+
+ new WP_HTML_Style_Attribute_Processor( $style_attribute_value );
+ }
+
+ /**
+ * @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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '--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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_CSS_Token_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( $style );
+ $this->assertTrue( $processor->next_declaration() );
+
+ $reread = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( 'color: red !important; color: color(display-p3 1 0 0);' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->set_value( 'green', false ) );
+
+ $this->assertSame( 'color: green; color: color(display-p3 1 0 0);', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::set_value
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_declaration_values_allow_leading_and_trailing_comments_as_trivia() {
+ $processor = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '--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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '--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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( 'C\\6f LOR: red' );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertTrue( $processor->set_value( 'blue' ) );
+ $this->assertSame( 'color: blue;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( '' );
+
+ $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 = new WP_HTML_Style_Attribute_Processor( '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
+ */
+ public function test_set_value_rejects_empty_value() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertFalse( $processor->set_value( '' ) );
+ $this->assertFalse( $processor->set_value( " \t" ) );
+
+ $this->assertSame( 'color', $processor->get_property_name() );
+ $this->assertSame( 'color: red; 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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+
+ $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::remove_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_remove_declaration_removes_adjacent_duplicate_declarations() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; color: blue; background: white;' );
+
+ while ( $processor->next_declaration( 'color' ) ) {
+ $this->assertTrue( $processor->remove_declaration() );
+ }
+
+ $this->assertSame( 'background: white;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @ticket 65738
+ *
+ * @covers ::next_declaration
+ * @covers ::remove_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_remove_declaration_in_a_named_loop_removes_every_matching_declaration() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; width: 1px; color: blue; color: green; height: 2px; COLOR: black' );
+
+ $removed = 0;
+ while ( $processor->next_declaration( 'color' ) ) {
+ $this->assertTrue( $processor->remove_declaration() );
+ ++$removed;
+ }
+
+ $this->assertSame( 4, $removed );
+ $this->assertSame( 'width: 1px; height: 2px;', $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 = new WP_HTML_Style_Attribute_Processor( 'color: red; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+
+ $this->assertNull( $processor->get_property_name() );
+ $this->assertNull( $processor->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 = new WP_HTML_Style_Attribute_Processor( '/*keep*/ color: red; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+ $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+ $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; /*keep*/ background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'background' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+ $this->assertSame( 'color: red; /*keep*/', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::remove_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_remove_declaration_with_invalid_fragments_preserves_remaining_structure() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red; invalid; background: white;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+ $this->assertSame( 'invalid; background: white;', $processor->get_updated_style() );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertSame( 'background', $processor->get_property_name() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_adds_duplicate_declaration() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;' );
+
+ $this->assertTrue( $processor->append_declaration( 'color', 'color(display-p3 1 0 0)', true ) );
+
+ $this->assertSame( 'color: red; color: color(display-p3 1 0 0) !important;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_preserves_multiple_appends_in_order() {
+ $processor = new WP_HTML_Style_Attribute_Processor( '' );
+
+ $this->assertTrue( $processor->append_declaration( 'color', 'red' ) );
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+
+ $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::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 = new WP_HTML_Style_Attribute_Processor( '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() );
+ }
+
+ /**
+ * @ticket 65738
+ *
+ * @covers ::append_declaration
+ * @covers ::remove_declaration
+ * @covers ::set_important
+ * @covers ::set_value
+ *
+ * @dataProvider data_mutations
+ *
+ * @param string $style Style text.
+ * @param array[] $operations Method name and arguments of each call. Every call must return true.
+ * @param int $index Index of the declaration the cursor reads next after the calls.
+ */
+ public function test_mutations_leave_the_declarations_a_new_processor_reads( string $style, array $operations, int $index ) {
+ $processor = new WP_HTML_Style_Attribute_Processor( $style );
+
+ foreach ( $operations as $operation ) {
+ $method = array_shift( $operation );
+ $this->assertTrue( $processor->$method( ...$operation ), "Expected {$method}() to return true." );
+ }
+
+ $reread = new WP_HTML_Style_Attribute_Processor( $processor->get_updated_style() );
+
+ $this->assertSame(
+ array_slice( self::read_remaining_declarations( $reread ), $index ),
+ self::read_remaining_declarations( $processor )
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_mutations(): array {
+ $style = 'color: red; width: calc(1px + 2px) !important; --x: "a" b; background: url(a.png)';
+ $open = 'color: red; width: calc(1px + var(--gap';
+
+ return array(
+ 'set_value() on the first declaration' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'set_value', 'rgb(1 2 3 / 50%)' ) ),
+ 0,
+ ),
+ 'set_value() on a middle declaration' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'set_value', 'calc(100% - 2 * var(--gap, 10px))', false ) ),
+ 1,
+ ),
+ 'set_important() adds the priority' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'set_important', true ) ),
+ 0,
+ ),
+ 'set_important() removes the priority' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'set_important', false ) ),
+ 1,
+ ),
+ 'set_important() closes blocks open at EOF' => array(
+ $open,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'set_important', true ) ),
+ 1,
+ ),
+ 'remove_declaration() on the first' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'remove_declaration' ) ),
+ 0,
+ ),
+ 'remove_declaration() on a middle one' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'remove_declaration' ) ),
+ 1,
+ ),
+ 'remove_declaration() on the last' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'next_declaration' ), array( 'next_declaration' ), array( 'remove_declaration' ) ),
+ 3,
+ ),
+ 'append_declaration() before the first' => array(
+ $style,
+ array( array( 'append_declaration', 'padding', '1px 2px' ) ),
+ 0,
+ ),
+ 'append_declaration() on a declaration' => array(
+ $style,
+ array( array( 'next_declaration' ), array( 'next_declaration' ), array( 'append_declaration', 'padding', '1px 2px', true ) ),
+ 1,
+ ),
+ 'append_declaration() closes blocks at EOF' => array(
+ $open,
+ array( array( 'append_declaration', 'padding', '1px 2px' ) ),
+ 0,
+ ),
+ 'mixed calls' => array(
+ $style,
+ array(
+ array( 'next_declaration' ),
+ array( 'set_value', 'blue', true ),
+ array( 'append_declaration', 'border', '1px solid' ),
+ array( 'set_important', false ),
+ array( 'next_declaration' ),
+ array( 'remove_declaration' ),
+ array( 'append_declaration', 'margin', '0' ),
+ array( 'next_declaration' ),
+ array( 'set_value', '"b" c' ),
+ array( 'set_important', true ),
+ array( 'next_declaration', 'background' ),
+ array( 'remove_declaration' ),
+ ),
+ 2,
+ ),
+ );
+ }
+
+ /**
+ * Reads the current declaration, or the next one when the cursor is not on one, and every declaration after it.
+ *
+ * @param WP_HTML_Style_Attribute_Processor $processor Processor whose cursor moves to the end.
+ * @return array[] Name, priority, value tokens and serialization of each declaration.
+ */
+ private static function read_remaining_declarations( WP_HTML_Style_Attribute_Processor $processor ): array {
+ $declarations = array();
+ if ( null === $processor->get_property_name() && ! $processor->next_declaration() ) {
+ return $declarations;
+ }
+
+ do {
+ $declarations[] = array(
+ 'name' => $processor->get_property_name(),
+ 'important' => $processor->is_important(),
+ 'tokens' => array_map(
+ static function ( WP_CSS_Token $token ): array {
+ return array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->get_start(), $token->get_length(), $token->is_terminated() );
+ },
+ $processor->get_value_tokens()
+ ),
+ 'serialized' => $processor->serialize_declaration(),
+ );
+ } while ( $processor->next_declaration() );
+
+ return $declarations;
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_treats_comments_as_trivia_for_separators() {
+ $processor = new WP_HTML_Style_Attribute_Processor( '/*keep*/' );
+
+ $this->assertTrue( $processor->append_declaration( 'color', 'red' ) );
+ $this->assertSame( '/*keep*/ color: red;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: red;/*keep*/' );
+
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+ $this->assertSame( 'color: red;/*keep*/ background: white;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x;/*keep*/);' );
+
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+ $this->assertSame( 'color: var(--x;/*keep*/); background: white;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_lowercases_ordinary_properties_and_preserves_custom_properties() {
+ $processor = new WP_HTML_Style_Attribute_Processor( '' );
+
+ $this->assertTrue( $processor->append_declaration( 'BackgroundColor', 'red' ) );
+ $this->assertTrue( $processor->append_declaration( '--Tone', 'warm' ) );
+
+ $this->assertSame( 'backgroundcolor: red; --Tone: warm;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::next_declaration
+ * @covers ::get_property_name
+ */
+ public function test_appended_declarations_can_be_inspected_by_the_cursor() {
+ $processor = new WP_HTML_Style_Attribute_Processor( '' );
+
+ $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 = new WP_HTML_Style_Attribute_Processor( 'color: red;' );
+
+ $this->assertTrue( $processor->next_declaration() );
+ $this->assertFalse( $processor->next_declaration() );
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+
+ $this->assertNull( $processor->get_property_name() );
+ $this->assertNull( $processor->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 = new WP_HTML_Style_Attribute_Processor( 'color: red; ' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertTrue( $processor->remove_declaration() );
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+
+ $this->assertSame( 'background: white;', $processor->get_updated_style() );
+ }
+
+ /**
+ * @covers ::append_declaration
+ * @covers ::get_updated_style
+ */
+ public function test_append_declaration_repairs_unclosed_eof_component_values() {
+ $processor = new WP_HTML_Style_Attribute_Processor( 'color: var(--x' );
+
+ $this->assertTrue( $processor->append_declaration( 'background', 'white' ) );
+ $this->assertSame( 'color: var(--x); background: white;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( 'background: url(foo' );
+
+ $this->assertTrue( $processor->append_declaration( 'color', 'red' ) );
+ $this->assertSame( 'background: url(foo); color: red;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( 'background: url(foo ' );
+
+ $this->assertTrue( $processor->append_declaration( 'border', '0' ) );
+ $this->assertSame( 'background: url(foo ); border: 0;', $processor->get_updated_style() );
+
+ $processor = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '} 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 = new WP_HTML_Style_Attribute_Processor( $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 = new WP_HTML_Style_Attribute_Processor( '--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 = new WP_HTML_Style_Attribute_Processor( 'c\\6f lor: red; --t\\6f ne: cool; --Tone: warm;' );
+
+ $this->assertTrue( $processor->next_declaration( 'color' ) );
+ $this->assertSame( 'color', $processor->get_property_name() );
+ $this->assertTrue( $processor->set_value( 'green' ) );
+ $this->assertSame( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '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 = new WP_HTML_Style_Attribute_Processor( '' );
+
+ $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 = new WP_HTML_Style_Attribute_Processor( $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, ' a ', 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(
+ 'false' => array( false ),
+ 'int' => array( 1 ),
+ 'float' => array( 1.5 ),
+ 'array' => array( array( 'color: red' ) ),
+ 'object' => array( new stdClass() ),
+ );
+ }
+
+ /**
+ * 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\\' ),
+ );
+ }
+}