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{E򐡳u}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( 'The WordPress logo', 'The WordPress logo', ), - '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\\' ), + ); + } +}