diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md new file mode 100644 index 0000000000000..49d0804a1d98e --- /dev/null +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -0,0 +1,364 @@ +# CSS Declaration List Filter Specification + +Status: implemented on `css-api/safecss-filter-attr` as the default path of +`safecss_filter_attr()` in `wp-includes/kses.php`. Each section states a +decision. The "Decisions" section records the questions that were open during +the prototype and how each was settled. + +## Purpose + +The declaration list filter replaces the parsing and checking inside +`safecss_filter_attr()`. It takes the decoded CSS text of a `style` attribute, +parses it as a declaration list, drops the declarations a policy rejects, and +returns a declaration list built from the accepted ones. + +The legacy function splits on `;`, matches property names as text, and tests +values with regular expressions. The filter parses with +`WP_HTML_Style_Attribute_Processor`, so the unit it accepts or rejects is the +same declaration a browser sees. + +## Home + +There is no new public function or class. `safecss_filter_attr()` keeps its +signature and docblock. It builds the allowed property list once, applies +`safe_style_css` once, and dispatches to one of two private helpers in +`kses.php`: + +- `_safecss_filter_attr_declarations( $css, $allowed_attr )`: this filter. +- `_safecss_filter_attr_legacy( $css, $allowed_attr )`: the pre-7.3.0 body, + unchanged except that it receives the list. + +The declarations helper uses three further private helpers: +`_safecss_filter_attr_value_is_allowed()`, `_safecss_filter_attr_url_is_allowed()` +and `_safecss_filter_attr_is_trivia()`. +All are `@access private`, `@internal`, `@since 7.3.0`. + +## Scope + +In scope: a declaration list, as in a `style` attribute or the body of one +rule. Out of scope: whole stylesheets, at-rules, selectors, and `@font-face` +descriptors. Those need their own parsing and their own policy. + +The filter does not decode HTML entities and does not encode its output. CSS +source text goes in and CSS source text comes out. Entity handling belongs to +the HTML layer, as it does for the legacy function's KSES caller. + +The filter does not call `wp_kses_no_null()`. The legacy function did, which +deleted NUL bytes and every `\0`-prefixed text run before parsing. The +processor handles NUL bytes as CSS does (U+FFFD), and deleting `\0` text would +corrupt valid escapes such as `\00e9`. A `\0` escape therefore decodes to +U+FFFD, as it does in a browser. + +## Parsing Model + +The filter sees only what the processor exposes as a declaration. Text the +processor skips is never checked and never emitted. This includes items with no +colon, at-rules, and declarations whose value contains a bad string, a bad URL, +an unmatched closing token, a top-level `!`, or a `{}` block mixed with other +tokens in a non-custom property. The processor's specification, "Invalid +Fragments", defines this and the splitting rule for a stray `}`. + +Consequence: the filter never has to judge malformed CSS. A declaration either +parses as CSS or is not there. + +## Value Access + +The filter reads each value through +`WP_HTML_Style_Attribute_Processor::get_value_tokens()`, the `WP_CSS_Token` +objects covering the value, and runs the checks on their decoded values. It +writes each accepted declaration with +`WP_HTML_Style_Attribute_Processor::serialize_declaration()` and has no +serializer of its own. + +## Rejection Unit + +One declaration. A rejected declaration is dropped whole. No declaration is +rewritten to make it acceptable, and no declaration is rejected because of a +neighbour. + +## Policy + +One rule applies to every declaration, for every property including custom +ones: + +0. **The end of the input.** CSS closes a block (a function, `(`, `[` or + `{`) and a comment, string or url token that the input ends inside. The + processor exposes the declaration, the rules below check it like any + other, and the output writes it closed: `width: calc(1px` gives + `width: calc(1px);` and `font-family: "abc` gives `font-family: "abc";`. A + `;` inside an open block is part of the value, so + `width: calc(1px; color: red` is one declaration, as a browser reads it. + +An empty `safe_style_css` array disables filtering. `safecss_filter_attr()` +returns the input with C0 control characters and `\0` sequences removed, +which is what the legacy function returns for an empty list, and does not +call the declarations helper. A filter result that is not an array is +replaced with the default list, on both paths. + +1. **Property name.** The decoded, lowercased name is in the `safe_style_css` + list, whose entries are lowercased once before matching; or the list + contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. Custom property + names are matched case-sensitively. List entries that are not strings are + ignored, so a list with no string entry allows no property, as in the + legacy function, which compared entries strictly. +2. **Functions.** Every function in the value, at any nesting depth, is in the + function allowlist: the legacy function's list, the math functions `abs`, + `sign`, `round`, `mod`, `rem`, `pow`, `sqrt`, `hypot`, `log`, `exp`, `sin`, + `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, the six gradient functions, + the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, + `oklab`, `oklch`, `color`, `color-mix`, `light-dark`, the filter + functions `blur`, `brightness`, `contrast`, `drop-shadow`, `grayscale`, + `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia`, and the easing + functions `cubic-bezier`, `steps`, `linear`. Names are decoded and + matched case-insensitively. There is no per-property list: a gradient or a + color function is allowed on every property. + + A function is added to the allowlist when it passes two tests. Security: + its result depends only on its arguments; it loads no resource and reads + no document, layout, scroll, input, author-defined code or condition. + Stability: Blink, Gecko and WebKit ship it without a prefix or a flag, as + MDN's browser-compat-data records. This is the CSS Working Group's rule + for shipping a pre-CR feature unprefixed, counted in engines rather than + browsers. Spec maturity is not a test: `min()`, `max()` and `clamp()` are + in a Working Draft and have shipped in every engine since 2020. +3. **URLs.** Every URL in the value, at any depth, whether a `url()` token or + `url("...")` with a string argument, is non-empty after trimming and is + returned unchanged by `wp_kses_bad_protocol()` with `wp_allowed_protocols()`. + `url(` with anything other than one string argument, closed by `)` or by + the end of the input, is invalid CSS and the declaration is dropped. There is no per-property list of URL-bearing + properties. `src()` is not in the function allowlist: it exists so `var()` + can supply a URL, which the filter cannot check. +4. **Structure.** In a non-custom property, the value has no bare `( )` block + and no `{ }` block at its top level. No standard property grammar accepts + either. A `[ ]` block is kept: `grid-template-columns: [a] 1fr` is valid. + A `( )` block nested in a function, as in `calc(1px + (2px * 3))` or a + `var()` fallback, is governed by that function's grammar and is kept. + Custom properties keep every block. +5. **Delimiters.** The value has no `&` delimiter token at any depth, in every + property including custom ones. The output contains no `&`: the serializer + escapes it in every other token, and no standard property value uses a + bare `&`, so a value that has one is rejected. The same applies to a + control character delimiter, U+0001 to U+001F other than whitespace or + U+007F, which the serializer writes raw, and to a `\` delimiter, which only + a backslash before a newline produces and which the serializer writes with + that newline. `<`, `>`, `=`, `` are kept; encoding the output for HTML is the caller's task. +6. **Escapes and comments.** Checks run on decoded values, so an escaped + function name or scheme is checked as what it denotes. Comments are + dropped; see "Output". + +Beyond rules 4 and 5 the policy checks what a value contains, not whether it +is valid for its property. Browser grammar is out of scope. + +## Output + +The output is built from the accepted declarations, not cut from the input: +it joins the `serialize_declaration()` text of each one with a space. Each +declaration is `property: value;`, with `!important` preserved as +` !important` before the `;`. Property names are the decoded names, escaped +as identifiers. Values are written from their tokens by +`WP_CSS_Token_Serializer::serialize()`, which follows CSS Syntax Level 3 §9: + +- Comments are dropped. Each whitespace run, including one broken by comments, + becomes one space. +- An empty comment is written between two tokens that would read as other + tokens when written together, as the §9 table lists, so `#a\62/**/c` + serializes as `#ab/**/c`. Between other pairs a dropped comment leaves + nothing: `a/**/,b` serializes as `a,b`. +- Identifiers, function names, at-keywords, hash names and dimension units are + re-escaped from their decoded values. A hash keeps its type flag: `#\31 a` + stays `#\31 a` and `#1a` stays `#1a`. +- Strings are written double-quoted. url tokens are written unquoted; + `url("...")` is a function with a string argument and keeps its quotes. +- Blocks still open at the end of the input, and strings and url tokens cut + off by it, are written closed, so the output never absorbs the `;` that + follows. + +Strings and url values hex-escape `\`, `<`, `>`, `&`, `,`, `;`, `{`, `}`, +quotes and control characters; url values also escape parentheses and +whitespace. A URL with a query string therefore serializes as +`url(a?x=1\26 y=2)`, which decodes to the same URL. + +Properties of the output: + +- It re-parses to the accepted declarations and nothing else. +- Filtering it again changes nothing. +- It ends with `;` when non-empty. Callers that tested for `;` to detect + multiple declarations, or that appended their own `;`, must accept both + forms while the legacy path exists. See "Callers". + +## Callers + +Core-owned callers adapted on this branch. Each accepts the legacy form (no +trailing `;`) and the new form (trailing `;`): + +- `WP_Style_Engine_CSS_Declarations::filter_declaration()` strips one trailing + `;` from the filtered declaration, restores the spacer after the colon when + prettifying, and then appends ` !important` when the result has no `;`. + `get_declarations_string()` appends `;` as before. The engine's output bytes + are unchanged except for the filter's value serialization (double-quoted + strings in `url()`). +- `get_block_wrapper_attributes()` strips one trailing `;` from the merged + `style` value, as it strips `;` from its inputs. +- `wp_get_layout_style()` filtered a bare value such as `800px`, which the + legacy function passed through and this filter drops as an item with no + colon. It now filters `max-width:` plus the value and keeps the value of the + result. +- `WP_Theme_JSON::is_safe_css_declaration()` keeps the legacy behavior: it + adds a `wp_kses_force_legacy_css_parser` callback that returns `true` for + its own call and removes it afterward. + +Files under `wp-includes/blocks/` are synced from the Gutenberg repository and +are not changed here. `post-featured-image.php` appends `;` after each +filtered declaration, which now gives `;;`: an empty declaration, which +browsers ignore. The other block callers put the result in a `style` +attribute, where a trailing `;` changes nothing. A Gutenberg change can drop +the appended `;`. + +## Hooks + +`safe_style_css` is honoured as today, applied once before dispatch, so both +paths see the same list. An empty array returns the input before dispatch, +and a result that is not an array is replaced with the default list (see +"Policy"). + +`wp_kses_force_legacy_css_parser` is new in 7.3.0. It receives `false`; +returning `true` selects the legacy helper. It matches the form of +`wp_kses_force_legacy_parser` and exists for a transition period so a site +can restore the previous output while it adapts. + +`safecss_filter_attr_allow_css` is deprecated in 7.3.0 and applied by the +legacy helper only. It exposed a regex test string that has no equivalent +here. When a callback is attached, the declarations helper calls +`_deprecated_hook()` once per call, naming no replacement, and does not +apply the hook. A site that needs the hook can +select the legacy helper through `wp_kses_force_legacy_css_parser`. + +## Differences From The Legacy Function + +Intended: + +- Property names match case-insensitively. +- Color functions, gradients and `url()` are allowed on every property. +- Functions may nest to any depth. +- A function not on the allowlist is rejected wherever it appears, including + inside a gradient, `calc()` or `minmax()`. The legacy function removed + `calc()` and `minmax()` with everything inside them, so it kept + `minmax(fit-content(200px), 1fr)`; the parser rejects it until + `fit-content()` is added. +- The filter, math and easing functions are allowed. The legacy function + rejected the filter functions, except `hue-rotate()`, which passed because + its pattern for `rotate(` matched inside the name. +- A declaration CSS would drop is dropped, even when the legacy function kept + its text. This includes an item with no colon, which the legacy function + passed through unchecked. +- Escapes are accepted, decoded for the checks, and re-escaped in the output. +- A `\0` escape decodes to U+FFFD instead of being deleted from the text. +- A `=` delimiter is kept. The legacy function rejected it. Both reject a + bare `&`. +- A control character delimiter rejects its declaration. The legacy function + deleted U+0000 to U+001F and kept the rest of the declaration, and kept + U+007F. +- A comment, string or url token cut off by the end of the input is closed, + and its declaration is checked like any other. The legacy function + rejected an item containing `/*` and an unclosed `url(`, and kept an + unclosed string as it was. +- A block left open at the end of the input is closed, and its declaration is + checked like any other. The legacy function rejected a value with an + unclosed `(`, which its bad-character test matched, and kept an unclosed + `[` or `{` as it was. +- A lone `{ }` block in a standard property is rejected, as the legacy + function rejected `}`. +- An allowed-list entry with capitals matches, since entries and names are + lowercased before matching. +- A `safe_style_css` result that is not an array is replaced with the default + list, on both paths. The legacy function returned the input for `null`, + `false`, `''` and `0`, and threw a `TypeError` for other values when the + input had a declaration. +- `safecss_filter_attr_allow_css` is deprecated and not applied. Most + plugins and themes in the WordPress.org directories that attach a callback + use it only to allow color, filter, math, easing or transform functions, + which the parser accepts by default. The remaining uses are `data:` URLs + and functions this policy excludes, such as `attr()`. + +Formatting only: one space after the colon and between declarations, +double-quoted strings and unquoted url tokens with hex-escaped punctuation, +comments dropped, trailing `;`. + +Unchanged: an empty `safe_style_css` array returns the input. + +On the 158 legacy test vectors: 20 identical, 119 formatting only, 2 +narrowings (`expression()` inside a gradient; an unmatched `)`), 17 widenings, +0 defects, idempotent on all 158. + +## Decisions + +Open during the prototype, now settled: + +- **Open blocks.** Closed, as CSS Syntax Level 3 closes a simple block or a + function at the end of the input ("consume a simple block", "consume a + function"), and checked like any other declaration. An earlier version rejected them, citing the legacy + function's rejection of an unbalanced parenthesis; the legacy function + kept an unclosed `[` or `{`, and a closed block gives the output the + meaning a browser reads from the input. This applies the same rule as + tokens cut off by the end of the input, so a url function with a string, + `url("a.png"`, and a url token, `url(a.png`, are both kept. +- **Bare `( )` and `{ }` blocks.** Rejected at the top level of a non-custom + property's value; kept when nested in a function and in custom properties. + Rejecting at any depth would drop `calc(3em + (10px * 2))`, which the legacy + function accepts and which is valid CSS. The processor already drops a + `{ }` block mixed with other tokens in a non-custom property; this rule + covers a value that is only a `{ }` block. +- **Tokens cut off by the end of the input.** Closed, as CSS Syntax Level 3 + closes them: a string or url token at the end of the input is returned + with a parse error (§4.3.5, §4.3.6), and a comment ends there. The + declaration is kept if the policy accepts it, and the output writes the + token closed. An earlier version dropped the last accepted declaration + instead. Browsers keep every such declaration, and the legacy function + kept `color: red; /*` and `font-family: "abc`, which that rule dropped. +- **Transition.** The filter is named `wp_kses_force_legacy_css_parser`, + after `wp_kses_force_legacy_parser`, defaults to `false`, and receives no + other argument. Duration is not fixed; the filter docblock says it exists + for a transition period. +- **Shared property list.** `safecss_filter_attr()` builds the list once and + passes it to both helpers. The prototype's copy is deleted. +- **Value access.** The token view of `WP_CSS_Token` objects. The + prototype's `get_value_source()` is not kept. +- **Serialization.** `serialize_declaration()` for each accepted declaration. + Token lists are not a public unit for writing CSS, and the filter's own + value serializer, which turned every comment run into a space, is deleted: + in Chrome `calc(1px/**/+/**/2px)` is invalid, and the space made it + `calc(1px + 2px)`, which is valid. +- **`wp_kses_no_null()`.** Not applied on the new path. See "Scope". + +## Tests + +`tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in +isolation, blocks left open at the end of the input in standard and custom +properties, comments, +strings and url tokens cut off by the end of the input, the structural rule in +standard, nested and custom positions, +`!important`, comments, one case per marked cell of the §9 table and a sample +of unmarked pairs, whitespace, escapes, the empty `safe_style_css` array, a +`safe_style_css` result that is not an array, list entries that are not strings, +the two filters, and idempotence over a provider of the inputs that exercise +the serializer. + +In `tests/phpunit/tests/kses.php`, `data_safecss_filter_attr` and +`data_kses_style_attr_with_url` serve both paths. The new output is compared +with `assertEqualStyleAttribute()`, so a row whose output changes only in form +keeps its expectation. A row whose output changes otherwise also lists the +pre-7.3.0 output, which the `_legacy` tests assert through `wp_kses_force_legacy_css_parser`. +`tools/Tests_Safecss_Filter_Differential.php` compares the two paths over both +providers and classifies each difference. + +Tests outside kses whose expectations changed only in form now compare style +values by their declarations: +`tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php` with +`assertEqualHTML()`, `tests/phpunit/tests/blocks/supportedStyles.php` with +`assertEqualStyleAttribute()` and `assertEqualHTML()`, and +`tests/phpunit/tests/style-engine/styleEngine.php` with +`assertEqualStyleAttribute()` for declaration lists without a selector. +One Style Engine test asserted legacy policy: that `safecss_filter_attr_allow_css` +fires and that `url()` is dropped on `line-height`. It now asserts the new +output. 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..4cf1aa54300d0 --- /dev/null +++ b/docs/wp-html-style-attribute-processor-spec.md @@ -0,0 +1,352 @@ +# WP_HTML_Style_Attribute_Processor Specification + +## Purpose + +`WP_HTML_Style_Attribute_Processor` inspects and modifies decoded CSS text from an +HTML `style` attribute. Its input is the declaration-list contents of the +attribute, excluding HTML attribute syntax, entity decoding, and any surrounding +declaration-block braces. + +The processor preserves the CSS declaration-list model. Duplicate declarations +remain distinct. Invalid CSS fragments are skipped or preserved according to CSS +parser semantics unless a requested mutation cannot be proven safe. + +The processor's north star is: + +1. preserve CSS structure; +2. preserve user intent; +3. preserve the semantic CSS value of any existing declaration it rewrites; +4. make minimal edits when doing so does not compromise the first three goals. + +## Construction + +Creation uses: + +```php +WP_HTML_Style_Attribute_Processor::create( $decoded_css_text ) +``` + +The constructor is private. `create()` accepts only string input and returns a +processor instance of the called class. The implementation must remain +compatible with WordPress' supported PHP versions, so the runtime signature may +use PHPDoc and explicit validation instead of PHP syntax unavailable in those +versions. + +Callers must not pass raw `WP_HTML_Tag_Processor::get_attribute( 'style' )` +results directly unless they have already checked that the result is a string. +Missing or boolean attributes are an HTML-layer concern, not accepted CSS text. + +Parsing is lazy. Creating a processor does not scan the style text until cursor +movement, mutation validation, or serialization requires it. + +## Declaration Model + +The processor models each CSS declaration as: + +```text +property-name: component-value-list !important? +``` + +The internal declaration data consists of: + +- decoded property name; +- authored property-name source when available; +- value component list source range and token index range; +- `!important` flag; +- nullable source range for the parsed priority span; +- source ranges needed for safe mutation and verification. + +`!important` is not part of the value, including for custom properties. + +## Public API + +Initial public methods: + +```php +public static function create( $decoded_css_text ); +public function next_declaration( ?string $property_name = null ): bool; +public function get_property_name(): ?string; +public function is_important(): ?bool; +public function get_value_tokens(): ?array; +public function serialize_declaration(): ?string; +public function set_value( string $value, ?bool $important = null ): bool; +public function set_important( bool $important ): bool; +public function remove_declaration(): bool; +public function append_declaration( string $property_name, string $value, bool $important = false ): bool; +public function get_updated_style(): string; +``` + +The processor exposes a declaration value as tokens, through +`get_value_tokens()`, and not as a string: there is no `get_value()` or +`get_raw_value()`. Consumers that check or re-serialize values need tokens; a +string would be re-tokenized by every consumer. + +Bookmarks, seek, and rewind are out of scope. Callers that need to rescan should +create a new processor from the updated style text. + +## Cursor Semantics + +`next_declaration()` advances a forward-only cursor over CSS declarations. + +All current-declaration getters return `null` when the cursor is not positioned +on a valid declaration. This includes `is_important()` and `get_value_tokens()`. + +After `remove_declaration()` succeeds, the cursor becomes invalid until +`next_declaration()` advances it. A second removal on the same invalid cursor +returns `false`. + +After `set_value()` or `set_important()` succeeds without removing the +declaration, the cursor remains on the same logical declaration. + +`append_declaration()` does not move the cursor. If the cursor was exhausted, it +remains invalid until a later `next_declaration()` advances to the appended +declaration. + +`next_declaration()` flushes pending safe edits before advancing because +advancement must reflect the updated declaration list. + +Current-position getters should reflect successful pending edits without forcing +a full reparse where practical, following HTML API style. + +Each successful mutation parses the whole style text again. Removing most of the +declarations of a long style one at a time therefore takes time that grows with +the square of its length. To keep some declarations, read them and join the +`serialize_declaration()` output of the ones to keep. + +## Property Names + +All public property-name arguments are decoded/plaintext CSS property names, not +CSS-escaped identifier source. + +Ordinary properties match ASCII case-insensitively. Custom properties match +exactly because casing is semantic. + +`get_property_name()` exposes CSS semantics: + +- ordinary property names are returned lowercase; +- custom property names preserve exact decoded casing. + +Edits and appends write the property name as `get_property_name()` returns it, +escaped as an identifier: ordinary properties lowercase, custom properties with +their exact casing. + +Appended property names must be valid plaintext CSS property names. The processor +does not escape arbitrary strings into identifiers. Custom properties follow the +same validity rule with the required `--` prefix. + +## Value Tokens + +`get_value_tokens()` returns the current declaration's value as a list of +`WP_CSS_Token` objects, or `null` when the cursor is not on a declaration. A +token's getters give its type (a `WP_CSS_Token_Processor` `TOKEN_*` constant), +type flag, decoded value, decoded dimension unit, byte offset and byte length in +the style text, and whether it ended at its closing delimiter. + +The list covers the value only. Leading and trailing whitespace and comments +and the `!important` priority are excluded; whitespace and comments between +value tokens are included. A declaration with no value tokens, such as +`color:;`, returns an empty list. + +Offsets index the current style text, which `get_updated_style()` returns. +Before any mutation that is the text passed to `create()`. A successful +mutation reparses the updated text, and the offsets follow it. + +Re-tokenizing the source slice from the first token's start to the end of the +last token with `WP_CSS_Token_Processor` yields the same type, flag, value, +unit and termination sequence. Consumers may rely on this to re-serialize a +value or to hand the slice to another tokenizer-based check. + +Two alternatives were considered. A `WP_CSS_Token_Processor` positioned over the +value's range would need range support in the tokenizer and give consumers a +forward-only cursor over tokens the processor already holds. A raw string would +be re-tokenized by every consumer. + +## Serializing A Declaration + +`serialize_declaration()` writes the current declaration from its parsed parts, +or returns `null` when the cursor is not on a declaration. The property name is +escaped as an identifier. The value is written from its tokens with +`WP_CSS_Token_Serializer::serialize()`: comments are dropped, each whitespace run +becomes one space, and blocks left open at the end of the style are closed. The +result is `name: value;` or `name: value !important;`, and CSS reads it as the same +declaration. + +This is the public way to write CSS from a parsed declaration. Token lists are +not a public unit for writing CSS: `WP_CSS_Token_Serializer` is internal. + +## Value Validation + +Mutation inputs accept CSS declaration values, not declaration-list text. + +The supplied value must fit safely in both of these declaration slots, depending +on the property being changed: + +```css +foo: ; +--foo: ; +``` + +Validation checks structure and CSS syntax safety, not property-specific browser +support. The processor should not maintain a browser-style matrix of supported +properties and value grammars. + +Values must be complete and self-contained. Reject inputs that: + +- are empty after CSS whitespace trimming; +- contain top-level declaration separators such as semicolons; +- contain a top-level `!important`, because priority is a separate argument; +- contain bad strings or bad URLs; +- contain unmatched delimiters or constructs that are valid only because CSS + closes them at EOF; +- cannot be proven to occupy only the declaration value slot. + +Nested semicolons and `!important` text inside balanced blocks, functions, +strings, or URLs are allowed when the tokenizer and parser say they are part of +the value. + +## Importance + +`set_value( $value, null )` preserves the existing important flag. +`set_value( $value, true )` sets it. +`set_value( $value, false )` clears it. + +`append_declaration()` receives importance as a separate boolean and rejects a +top-level `!important` in `$value`. + +`set_important()` changes only declaration priority. + +When clearing importance, the processor may remove only the parsed priority span +when that is safe. If minimal removal is unsafe, it may normalize the current +declaration as long as it preserves CSS structure and the existing value's +semantics. If that cannot be proven, it returns `false`. + +When setting importance, the processor adds canonical ` !important` at the +parsed value end or normalizes the declaration if necessary. It must verify that +the updated declaration reparses as important. + +## Mutation Atomicity And Verification + +Invalid or inapplicable mutations return `false` without `_doing_it_wrong()`. + +Failed mutations are atomic: style text, cursor state, and previous successful +edits are preserved. + +A mutation returning `true` guarantees that `get_updated_style()` includes it. + +Every successful mutation must preserve the expected declaration-list structure. +`set_value()` must not let an input value merge, split, hide, or otherwise change +declarations beyond the intended current declaration. + +The public contract describes guarantees, not the exact validation mechanism. +Synthetic declarations, parser comparisons, token-level checks, and reparsing are +implementation details. + +Multiple mutations to the same logical declaration collapse to the final +intended state. Mutations to different declarations in one pass are allowed when +each mutation can be independently verified against the updated declaration +list. + +## Formatting And Normalization + +The processor preserves surrounding text, comments, ignored invalid fragments, +and authored spelling when cheap and safe. + +Minimal edits are a lower priority than safety, correctness, preserved CSS +structure, and semantic preservation. Any normalization required to satisfy +those higher priorities is allowed, but normalization that rewrites an existing +declaration value must be strict: it must serialize from parsed token/component +data with equivalent CSS semantics. If equivalence cannot be proven, the +mutation returns `false`. + +`set_value()` and `append_declaration()` write a declaration as +`serialize_declaration()` does: `name: value;` or `name: value !important;`, with +the name escaped as an identifier and the value written from its tokens. Comments +and whitespace before and after a supplied value are removed, comments inside it +are dropped, and each whitespace run inside it becomes one space. Nothing is +copied from the supplied text. + +`set_value()` rewrites the whole current declaration; text around it is +unchanged. + +## Invalid Fragments + +Declaration discovery follows CSS semantics. If CSS would ignore text as a +declaration in a style attribute declaration-list context, the processor does +not expose it as a declaration. That includes a declaration whose value, apart +from its priority, contains: + +- a bad string or a bad URL; +- a closing `)`, `]`, or `}` that does not match the innermost open block; +- a top-level `!`; +- a top-level `{}` block alongside other tokens, unless the property is a custom + property. + +Blocks and functions left open at EOF are closed by CSS, so a value ending +inside one is exposed. + +Splitting follows CSS Style Attributes and the published CSS Syntax Level 3 +"parse a list of declarations": a stray `}` is an invalid token, and the +invalid declaration runs to the next top-level semicolon. The CSS Syntax editor's +draft ends parsing at a top-level `}` instead; browsers and web-platform-tests do +not, and csswg-drafts#11113 tracks the conflict. + +Ignored invalid fragments are preserved where possible. Mutations touching a +style containing invalid chunks must verify that the declaration-list structure +remains safe. If preservation and a requested mutation are incompatible, the +mutation returns `false` or normalizes only the range necessary to preserve +semantics and structure. + +## Append And EOF Repair + +`append_declaration()` may succeed even when trailing text is malformed, but only +when the processor can preserve existing CSS semantics. + +When trailing declaration text is valid only because CSS closes an unclosed +string, comment, function or block at EOF, appending must materialize the +missing closing delimiters before inserting the new declaration. A string is +closed with the quote it starts with and a comment with `*/`. A `\` at EOF +cannot be closed this way, so append returns `false`. + +Example: + +```css +color: var(--x +``` + +Appending `background: white` should produce a structurally safe equivalent such +as: + +```css +color: var(--x); background: white; +``` + +EOF repair is part of a successful append and appears in `get_updated_style()`. +Repair should insert only the specific missing delimiters discovered from parser +or tokenizer state, plus the boundary needed before the appended declaration. If +the processor cannot determine a precise repair, append returns `false`. + +EOF repair is essential for append. `set_value()` replacement values must be +self-contained and do not receive EOF repair. + +## Test Requirements + +Tests should cover: + +- private construction and `create()` behavior; +- decoded string input boundary; +- declaration traversal, duplicate preservation, and property-name matching; +- getter `null` behavior off-cursor; +- absence of public raw value getter in the first API; +- value token view: `null` off-cursor, trivia and priority exclusion, offsets + into the style text, and the re-tokenization invariant; +- `set_value()` priority preservation, setting, clearing, and empty-value + 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/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 3036c581fd67b..22d57fe2c52b1 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -620,9 +620,21 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false ? $wide_size : ( $content_size && ! $has_wide_size_override ? $content_size : 'var(--wp--style--global--wide-size, none)' ); - // Make sure there is a single CSS rule, and all tags are stripped for security. - $all_max_width_value = safecss_filter_attr( explode( ';', $all_max_width_value )[0] ); - $wide_max_width_value = safecss_filter_attr( explode( ';', $wide_max_width_value )[0] ); + /* + * Make sure there is a single CSS rule, and all tags are stripped for security. + * safecss_filter_attr() filters declarations, so the value is filtered as a + * `max-width` declaration and the value of the result is kept. + */ + $filter_max_width = static function ( $value ) { + $declaration = safecss_filter_attr( 'max-width:' . explode( ';', $value )[0] ); + if ( ! str_starts_with( $declaration, 'max-width:' ) ) { + return ''; + } + $value = substr( $declaration, strlen( 'max-width:' ) ); + return str_ends_with( $value, ';' ) ? substr( $value, 0, -1 ) : $value; + }; + $all_max_width_value = $filter_max_width( $all_max_width_value ); + $wide_max_width_value = $filter_max_width( $wide_max_width_value ); $margin_left = 'left' === $justify_content ? '0 !important' : 'auto !important'; $margin_right = 'right' === $justify_content ? '0 !important' : 'auto !important'; diff --git a/src/wp-includes/class-wp-block-supports.php b/src/wp-includes/class-wp-block-supports.php index 0a93c2c45ab55..315ed26327505 100644 --- a/src/wp-includes/class-wp-block-supports.php +++ b/src/wp-includes/class-wp-block-supports.php @@ -219,7 +219,9 @@ function get_block_wrapper_attributes( $extra_attributes = array() ) { rtrim( trim( $extra_attribute ), ';' ), ) ); - return safecss_filter_attr( implode( ';', array_filter( $styles ) ) ); + $style = safecss_filter_attr( implode( ';', array_filter( $styles ) ) ); + // safecss_filter_attr() may end the list with `;`. The merged attribute has none, like its inputs. + return str_ends_with( $style, ';' ) ? substr( $style, 0, -1 ) : $style; }, 'class' => static function ( $new_attribute, $extra_attribute ) { $classes = array_merge( diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index e3f0721343205..4c369c9700aee 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -5429,7 +5429,18 @@ protected static function remove_insecure_styles( $input ) { */ protected static function is_safe_css_declaration( $property_name, $property_value ) { $style_to_validate = $property_name . ': ' . $property_value; - $filtered = esc_html( safecss_filter_attr( $style_to_validate ) ); + + // Theme JSON validation uses the legacy CSS filter until this class builds declarations with the CSS API. + $force_legacy_css_parser = static function () { + return true; + }; + add_filter( 'wp_kses_force_legacy_css_parser', $force_legacy_css_parser, PHP_INT_MAX ); + try { + $filtered = esc_html( safecss_filter_attr( $style_to_validate ) ); + } finally { + remove_filter( 'wp_kses_force_legacy_css_parser', $force_legacy_css_parser, PHP_INT_MAX ); + } + return ! empty( trim( $filtered ) ); } 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..3f8f1e2c3d182 --- /dev/null +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -0,0 +1,1813 @@ + 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 = WP_CSS_Token_Processor::create( '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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( '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..2af6b4202c2b9 --- /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 = WP_CSS_Token_Processor::create( '"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 = WP_CSS_Token_Processor::create( '"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..fdf9a63fa52da --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-style-attribute-processor.php @@ -0,0 +1,1814 @@ + + */ + 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; + + /** + * Lexical updates to apply to the original style attribute value. + * + * @since 7.3.0 + * @ignore + * + * @var array + */ + private $lexical_updates = array(); + + /** + * Sequence counter used to keep lexical updates in queue order. + * + * @since 7.3.0 + * @ignore + * + * @var int + */ + private $lexical_update_order = 0; + + /** + * Constructor. + * + * Do not instantiate directly. Use + * {@see WP_HTML_Style_Attribute_Processor::create()} instead. + * + * @since 7.3.0 + * @ignore + * + * @param string $style Decoded CSS text value from a style attribute. + */ + private function __construct( string $style ) { + $this->style = $style; + } + + /** + * Creates a processor for decoded CSS text from a style attribute. + * + * @since 7.3.0 + * + * @param string $decoded_css_text Decoded CSS text value from a style attribute. + * @return static Created processor. + */ + public static function create( $decoded_css_text ) { + if ( ! is_string( $decoded_css_text ) ) { + throw new TypeError( __METHOD__ . '(): Argument #1 ($decoded_css_text) must be of type string' ); + } + + return new static( $decoded_css_text ); + } + + /** + * Moves to the next declaration in the style attribute value. + * + * When a property name is provided, normal CSS properties are matched + * ASCII-case-insensitively while custom properties are matched exactly. + * + * @since 7.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 `create()`. + * + * @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; + } + + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['start'], + $declaration['after'] - $declaration['start'], + $text, + $is_important, + $parsed_value + ) + ) { + return false; + } + + $this->queue_lexical_update( + $declaration['start'], + $declaration['after'] - $declaration['start'], + $text, + $this->current_declaration + ); + + $this->apply_lexical_updates( $this->current_declaration ); + + return true; + } + + /** + * Sets the !important priority of the current declaration. + * + * @since 7.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_at = $declaration['value_end']; + $insert_text = ' !important'; + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['value_end'], + 0, + $insert_text, + true + ) + ) { + $eof_repair = strlen( $this->style ) === $declaration['after'] + ? $this->get_eof_repair( $declaration['after'] ) + : null; + $current_value = substr( $this->style, $declaration['value_start'], $declaration['after'] - $declaration['value_start'] ); + + if ( + null === $eof_repair || + '' === $eof_repair || + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['after'], + 0, + $eof_repair . $insert_text, + true, + trim( $current_value . $eof_repair, self::WHITESPACE ) + ) + ) { + return false; + } + + $insert_at = $declaration['after']; + $insert_text = $eof_repair . $insert_text; + } + + $this->queue_lexical_update( + $insert_at, + 0, + $insert_text, + $this->current_declaration + ); + } else { + if ( null === $declaration['important_start'] || null === $declaration['important_end'] ) { + return false; + } + + if ( + ! $this->is_current_declaration_replacement_safe( + $declaration, + $declaration['important_start'], + $declaration['important_end'] - $declaration['important_start'], + '', + false + ) + ) { + return false; + } + + $this->queue_lexical_update( + $declaration['important_start'], + $declaration['important_end'] - $declaration['important_start'], + '', + $this->current_declaration + ); + } + + $this->apply_lexical_updates( $this->current_declaration ); + + return true; + } + + /** + * Removes the current declaration from the style attribute value. + * + * Only the current declaration is removed. Other declarations with the same + * property name remain in place. + * + * Each call parses the whole style text again, so removing most of the + * declarations of a long style one at a time is slow. To keep some + * declarations, join the {@see WP_HTML_Style_Attribute_Processor::serialize_declaration()} + * output of the ones to keep instead. + * + * @since 7.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 ) ) ? ' ' : ''; + + if ( ! $this->is_remove_declaration_safe( $this->current_declaration, $remove_start, $remove_end - $remove_start, $replacement ) ) { + return false; + } + + $this->queue_lexical_update( + $remove_start, + $remove_end - $remove_start, + $replacement, + $this->current_declaration + ); + + $this->apply_lexical_updates( $this->current_declaration - 1, true ); + + return true; + } + + /** + * Appends a declaration to the style attribute value. + * + * Appending does not remove or replace existing declarations with the same + * property name. The declaration is written as serialize_declaration() + * writes it: the name is escaped as an identifier and the value is + * written from its tokens. + * + * @since 7.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; + } + + if ( ! $this->is_parseable_append( $repair_at, $eof_repair, $separator, $declaration_text, $old_declaration_count, $name, $parsed_value, $important ) ) { + return false; + } + + $this->queue_lexical_update( + $repair_at, + 0, + $eof_repair . $separator . $declaration_text, + null + ); + + $append_after_exhausted_cursor = $this->current_declaration >= $old_declaration_count; + $current_after_append = $append_after_exhausted_cursor + ? $old_declaration_count - 1 + : $this->current_declaration; + + $this->apply_lexical_updates( + $current_after_append, + $this->current_declaration_removed || $append_after_exhausted_cursor + ); + + return true; + } + + /** + * Returns the updated decoded CSS text value for the style attribute. + * + * @since 7.3.0 + * + * @return string Updated decoded CSS text value for the style attribute. + */ + public function get_updated_style(): string { + if ( empty( $this->lexical_updates ) ) { + return $this->style; + } + + usort( + $this->lexical_updates, + static function ( $a, $b ) { + if ( $a['start'] !== $b['start'] ) { + return $a['start'] - $b['start']; + } + + return $a['order'] - $b['order']; + } + ); + + $updates = $this->merge_overlapping_lexical_updates( $this->lexical_updates ); + + $bytes_already_copied = 0; + $output = ''; + + foreach ( $updates as $update ) { + $output .= substr( $this->style, $bytes_already_copied, $update['start'] - $bytes_already_copied ); + $output .= $update['text']; + $bytes_already_copied = $update['start'] + $update['length']; + } + + $output .= substr( $this->style, $bytes_already_copied ); + + return $output; + } + + /** + * Parses CSS tokens and declarations from the style attribute value. + * + * @since 7.3.0 + * @ignore + */ + private function parse(): void { + $this->tokens = array(); + $this->declarations = array(); + + $processor = WP_CSS_Token_Processor::create( $this->style ); + if ( null === $processor ) { + $this->parsed = true; + return; + } + + while ( $processor->next_token() ) { + $this->tokens[] = $processor->get_token(); + } + + $this->parse_declaration_list(); + $this->parsed = true; + } + + /** + * Lazily parses the style attribute value when declaration access needs it. + * + * @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 ]; + } + + /** + * Checks whether a replacement 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 bool Whether the replacement is safe. + */ + private function is_current_declaration_replacement_safe( array $declaration, int $start, int $length, string $text, bool $expected_important, ?string $expected_value = null ): bool { + $candidate_style = substr( $this->style, 0, $start ) . $text . substr( $this->style, $start + $length ); + $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); + + if ( + count( $candidate->declarations ) !== count( $this->declarations ) || + ! isset( $candidate->declarations[ $this->current_declaration ] ) + ) { + return false; + } + + $updated_declaration = $candidate->declarations[ $this->current_declaration ]; + if ( + $updated_declaration['name'] !== $declaration['name'] || + $updated_declaration['important'] !== $expected_important + ) { + return false; + } + + $old_value = trim( + substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + $new_value = trim( + substr( $candidate_style, $updated_declaration['value_start'], $updated_declaration['value_end'] - $updated_declaration['value_start'] ), + self::WHITESPACE + ); + + return null === $expected_value ? $old_value === $new_value : $expected_value === $new_value; + } + + /** + * Checks whether removing a declaration preserves remaining structure. + * + * @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 bool Whether the removal is safe. + */ + private function is_remove_declaration_safe( int $removed_declaration, int $start, int $length, string $text ): bool { + $candidate_style = substr( $this->style, 0, $start ) . $text . substr( $this->style, $start + $length ); + $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); + + if ( count( $candidate->declarations ) !== count( $this->declarations ) - 1 ) { + return false; + } + + $candidate_index = 0; + foreach ( $this->declarations as $index => $declaration ) { + if ( $removed_declaration === $index ) { + continue; + } + + if ( ! isset( $candidate->declarations[ $candidate_index ] ) ) { + return false; + } + + $candidate_declaration = $candidate->declarations[ $candidate_index ]; + $value = trim( + substr( $this->style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + $candidate_value = trim( + substr( $candidate_style, $candidate_declaration['value_start'], $candidate_declaration['value_end'] - $candidate_declaration['value_start'] ), + self::WHITESPACE + ); + + if ( + $candidate_declaration['name'] !== $declaration['name'] || + $candidate_declaration['important'] !== $declaration['important'] || + $candidate_value !== $value + ) { + return false; + } + + ++$candidate_index; + } + + return true; + } + + /** + * Gets the parsed value source from a serialized declaration. + * + * @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 + ); + } + + /** + * Applies queued lexical updates and reparses the updated style attribute value. + * + * @since 7.3.0 + * @ignore + * + * @param int $current_declaration Declaration index to keep before the next cursor advance. + * @param bool $current_declaration_removed Optional. Whether the current declaration was removed. + */ + private function apply_lexical_updates( int $current_declaration, bool $current_declaration_removed = false ): void { + $this->style = $this->get_updated_style(); + + $this->lexical_updates = array(); + $this->lexical_update_order = 0; + $this->current_declaration = $current_declaration; + $this->current_declaration_removed = $current_declaration_removed; + $this->parsed = false; + + $this->parse(); + + if ( $this->current_declaration >= count( $this->declarations ) ) { + $this->current_declaration = count( $this->declarations ); + } + } + + /** + * Queues a lexical update, replacing any earlier update for the same declaration. + * + * @since 7.3.0 + * @ignore + * + * @param int $start Byte offset at which to start the update. + * @param int $length Number of bytes to replace. + * @param string $text Replacement text. + * @param int|null $declaration Declaration index associated with the update. + */ + private function queue_lexical_update( int $start, int $length, string $text, ?int $declaration ): void { + if ( null !== $declaration ) { + foreach ( $this->lexical_updates as $index => $update ) { + if ( $update['declaration'] === $declaration ) { + unset( $this->lexical_updates[ $index ] ); + } + } + } + + $this->lexical_updates[] = array( + 'start' => $start, + 'length' => $length, + 'text' => $text, + 'declaration' => $declaration, + 'order' => $this->lexical_update_order++, + ); + } + + /** + * Checks whether an append has already been queued at a byte offset. + * + * @since 7.3.0 + * @ignore + * + * @param int $insert_at Byte offset. + * @return bool Whether an append has already been queued at the offset. + */ + private function has_queued_append_at( int $insert_at ): bool { + foreach ( $this->lexical_updates as $update ) { + if ( null === $update['declaration'] && $insert_at === $update['start'] && 0 === $update['length'] ) { + return true; + } + } + + return false; + } + + /** + * Gets the separator to use before an appended declaration. + * + * Comments and whitespace are CSS parser trivia. They do not determine + * whether the existing declaration list needs a semicolon before appending. + * + * @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 { + if ( $this->has_queued_append_at( $insert_at ) ) { + return ' '; + } + + $last_token = $this->get_last_top_level_non_ignored_token_before( $insert_at ); + + if ( null === $last_token ) { + return $insert_at > 0 ? ' ' : ''; + } + + return WP_CSS_Token_Processor::TOKEN_SEMICOLON === $last_token->get_type() ? ' ' : '; '; + } + + /** + * Gets the last top-level non-ignored token before a byte offset. + * + * @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 ) ); + } + + /** + * Checks whether an appended declaration will parse as a new declaration. + * + * Malformed existing declarations can leave the append point inside an + * unclosed component value. In that case preserving the original text and + * appending a top-level declaration are incompatible. + * + * @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 bool Whether the appended declaration is parseable. + */ + private function is_parseable_append( int $insert_at, string $eof_repair, string $separator, string $declaration_text, int $old_declaration_count, string $expected_name, string $expected_value, bool $expected_important ): bool { + $expected_start = $insert_at + strlen( $eof_repair ) + strlen( $separator ); + $expected_after = $expected_start + strlen( $declaration_text ); + $candidate_style = substr( $this->style, 0, $insert_at ) . $eof_repair . $separator . $declaration_text . substr( $this->style, $insert_at ); + $candidate = new self( $candidate_style ); + $candidate->ensure_parsed(); + + if ( count( $candidate->declarations ) !== $old_declaration_count + 1 ) { + return false; + } + + foreach ( $candidate->declarations as $declaration ) { + if ( $expected_start === $declaration['start'] && $expected_after === $declaration['after'] ) { + $parsed_value = trim( + substr( $candidate_style, $declaration['value_start'], $declaration['value_end'] - $declaration['value_start'] ), + self::WHITESPACE + ); + + return ( + $expected_name === $declaration['name'] && + $expected_value === $parsed_value && + $expected_important === $declaration['important'] + ); + } + } + + return false; + } + + /** + * Merges overlapping lexical updates into non-overlapping replacements. + * + * Adjacent declaration removals may share separator whitespace, and appends + * may be queued inside a range removed from the original style. This method + * collapses those updates while preserving the requested replacement text. + * + * @since 7.3.0 + * @ignore + * + * @param array $updates Lexical updates. + * @return array Merged updates. + */ + private function merge_overlapping_lexical_updates( array $updates ): array { + $merged = array(); + + foreach ( $updates as $update ) { + if ( empty( $merged ) ) { + $merged[] = $update; + continue; + } + + $last_index = count( $merged ) - 1; + $last = $merged[ $last_index ]; + $last_end = $last['start'] + $last['length']; + $update_end = $update['start'] + $update['length']; + + if ( $update['start'] > $last_end ) { + $merged[] = $update; + continue; + } + + $text = $update['text']; + if ( 0 === $last['start'] && '' === $last['text'] && 0 === $update['length'] ) { + $text = ltrim( $text, ';' . self::WHITESPACE ); + } + + $merged[ $last_index ]['length'] = max( $last_end, $update_end ) - $last['start']; + $merged[ $last_index ]['text'] = $last['text'] . $text; + } + + foreach ( $merged as $index => $update ) { + if ( 0 !== $update['start'] || '' !== $update['text'] ) { + continue; + } + + $end = $update['length']; + while ( $end < strlen( $this->style ) && false !== strpos( self::WHITESPACE, $this->style[ $end ] ) ) { + ++$end; + } + + $merged[ $index ]['length'] = $end; + } + + return $merged; + } + + /** + * Formats a declaration as `name: value;` or `name: value !important;`. + * + * @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 = WP_CSS_Token_Processor::create( $value ); + $tokens = array(); + while ( $processor->next_token() ) { + $tokens[] = $processor->get_token(); + } + + $start = 0; + $end = count( $tokens ); + while ( $start < $end && $this->is_ignored_token( $tokens[ $start ] ) ) { + ++$start; + } + while ( $end > $start && $this->is_ignored_token( $tokens[ $end - 1 ] ) ) { + --$end; + } + + return WP_CSS_Token_Serializer::serialize( array_slice( $tokens, $start, $end - $start ) ); + } + + /** + * Checks whether two property names match. + * + * @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 = WP_CSS_Token_Processor::create( $property_name ); + if ( null === $processor || ! $processor->next_token() ) { + return false; + } + + if ( + WP_CSS_Token_Processor::TOKEN_IDENT !== $processor->get_token_type() || + $processor->get_token_start() !== 0 || + $processor->get_token_length() !== strlen( $property_name ) + ) { + return false; + } + + if ( $processor->next_token() ) { + return false; + } + + $sentinel_processor = WP_CSS_Token_Processor::create( $property_name . ': sentinel' ); + if ( null === $sentinel_processor || ! $sentinel_processor->next_token() ) { + return false; + } + + if ( + WP_CSS_Token_Processor::TOKEN_IDENT !== $sentinel_processor->get_token_type() || + $sentinel_processor->get_token_start() !== 0 || + $sentinel_processor->get_token_length() !== strlen( $property_name ) + ) { + return false; + } + + return ( + $sentinel_processor->next_token() && + WP_CSS_Token_Processor::TOKEN_COLON === $sentinel_processor->get_token_type() + ); + } + + /** + * Checks whether a CSS declaration value is syntactically safe to insert. + * + * @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 = WP_CSS_Token_Processor::create( $value . ';' . $sentinel_property . ':1' ); + if ( null === $processor ) { + return false; + } + + $stack = array(); + $top_level_tokens = array(); + $found_sentinel = false; + + while ( $processor->next_token() ) { + $type = $processor->get_token_type(); + + if ( empty( $stack ) && WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $type && WP_CSS_Token_Processor::TOKEN_COMMENT !== $type ) { + if ( WP_CSS_Token_Processor::TOKEN_SEMICOLON === $type ) { + if ( strlen( $value ) !== $processor->get_token_start() ) { + return false; + } + + $found_sentinel = true; + break; + } + + $top_level_tokens[] = array( + 'type' => $type, + 'value' => $processor->get_token()->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-includes/kses.php b/src/wp-includes/kses.php index 065d984b35db3..e98e448cad1ea 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3693,6 +3693,9 @@ function kses_init() { * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. * Added support for `background-clip`. + * @since 7.3.0 Parses the declaration list with WP_HTML_Style_Attribute_Processor. + * + * @see 'wp_kses_force_legacy_css_parser' to opt-out of the new parser-based implementation. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3705,260 +3708,313 @@ function safecss_filter_attr( $css, $deprecated = '' ) { _deprecated_argument( __FUNCTION__, '2.8.1' ); // Never implemented. } - $css = wp_kses_no_null( $css ); - $css = str_replace( array( "\n", "\r", "\t" ), '', $css ); + $default_allowed_attr = array( + 'background', + 'background-color', + 'background-image', + 'background-position', + 'background-repeat', + 'background-size', + 'background-attachment', + 'background-blend-mode', + 'background-clip', + '-webkit-background-clip', + '-webkit-text-fill-color', + + 'border', + 'border-radius', + 'border-width', + 'border-color', + 'border-style', + 'border-right', + 'border-right-color', + 'border-right-style', + 'border-right-width', + 'border-bottom', + 'border-bottom-color', + 'border-bottom-left-radius', + 'border-bottom-right-radius', + 'border-bottom-style', + 'border-bottom-width', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + 'border-left', + 'border-left-color', + 'border-left-style', + 'border-left-width', + 'border-top', + 'border-top-color', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-top-style', + 'border-top-width', + 'border-top-left-radius', + 'border-top-right-radius', + + 'border-spacing', + 'border-collapse', + 'caption-side', + + 'columns', + 'column-count', + 'column-fill', + 'column-gap', + 'column-rule', + 'column-span', + 'column-width', + + 'display', + + 'color', + 'filter', + 'font', + 'font-family', + 'font-size', + 'font-style', + 'font-variant', + 'font-weight', + 'letter-spacing', + 'line-height', + 'text-align', + 'text-decoration', + 'text-indent', + 'text-shadow', + 'text-transform', + 'white-space', + + 'height', + 'min-height', + 'max-height', + + 'width', + 'min-width', + 'max-width', + + 'margin', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-top', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + + 'padding', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-top', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', + + 'flex', + 'flex-basis', + 'flex-direction', + 'flex-flow', + 'flex-grow', + 'flex-shrink', + 'flex-wrap', + + 'gap', + 'column-gap', + 'row-gap', + + 'grid-template-columns', + 'grid-auto-columns', + 'grid-column-start', + 'grid-column-end', + 'grid-column', + 'grid-column-gap', + 'grid-template-rows', + 'grid-auto-rows', + 'grid-row-start', + 'grid-row-end', + 'grid-row', + 'grid-row-gap', + 'grid-gap', + + 'justify-content', + 'justify-items', + 'justify-self', + 'align-content', + 'align-items', + 'align-self', + + 'clear', + 'cursor', + 'direction', + 'float', + 'list-style-type', + 'object-fit', + 'object-position', + 'opacity', + 'overflow', + 'vertical-align', + 'writing-mode', + + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'box-shadow', + 'aspect-ratio', + 'container-type', - $allowed_protocols = wp_allowed_protocols(); + 'fill', + 'fill-opacity', + 'fill-rule', - /** @todo Parse enough CSS to split rules without breaking on things like quoted strings. */ - $css_array = explode( ';', trim( $css ) ); + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + + 'color-interpolation', + 'color-interpolation-filters', + 'paint-order', + 'stop-color', + 'stop-opacity', + 'flood-color', + 'flood-opacity', + 'lighting-color', + + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + + 'clip-path', + 'clip-rule', + 'mask', + 'mask-type', + + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'x', + 'y', + 'd', + + 'alignment-baseline', + 'baseline-shift', + 'dominant-baseline', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'text-anchor', + 'unicode-bidi', + 'word-spacing', + + 'font-size-adjust', + 'font-stretch', + + 'color-rendering', + 'image-rendering', + 'shape-rendering', + 'text-rendering', + 'vector-effect', + + 'transform', + 'transform-origin', + + 'pointer-events', + 'visibility', + + 'anchor-name', + 'anchor-scope', + 'position-anchor', + 'position-area', + 'position-try', + 'position-try-fallbacks', + 'position-try-order', + 'position-visibility', + + // Custom CSS properties. + '--*', + ); /** * Filters the list of allowed CSS attributes. * + * Entries that are not strings are ignored, so an array with no string + * entry allows no property. A value that is not an array is replaced with + * the default list. + * + * An empty array disables filtering: the input is returned with C0 control + * characters and `\0` sequences removed. + * * @since 2.8.1 * @since 7.1.0 Added support for SVG presentation attributes. * @since 7.2.0 Added support for `background-clip` and its vendor prefixed companions. + * @since 7.3.0 A value that is not an array is replaced with the default list. * * @param string[] $attr Array of allowed CSS attributes. */ - $allowed_attr = apply_filters( - 'safe_style_css', - array( - 'background', - 'background-color', - 'background-image', - 'background-position', - 'background-repeat', - 'background-size', - 'background-attachment', - 'background-blend-mode', - 'background-clip', - '-webkit-background-clip', - '-webkit-text-fill-color', - - 'border', - 'border-radius', - 'border-width', - 'border-color', - 'border-style', - 'border-right', - 'border-right-color', - 'border-right-style', - 'border-right-width', - 'border-bottom', - 'border-bottom-color', - 'border-bottom-left-radius', - 'border-bottom-right-radius', - 'border-bottom-style', - 'border-bottom-width', - 'border-bottom-right-radius', - 'border-bottom-left-radius', - 'border-left', - 'border-left-color', - 'border-left-style', - 'border-left-width', - 'border-top', - 'border-top-color', - 'border-top-left-radius', - 'border-top-right-radius', - 'border-top-style', - 'border-top-width', - 'border-top-left-radius', - 'border-top-right-radius', - - 'border-spacing', - 'border-collapse', - 'caption-side', - - 'columns', - 'column-count', - 'column-fill', - 'column-gap', - 'column-rule', - 'column-span', - 'column-width', - - 'display', - - 'color', - 'filter', - 'font', - 'font-family', - 'font-size', - 'font-style', - 'font-variant', - 'font-weight', - 'letter-spacing', - 'line-height', - 'text-align', - 'text-decoration', - 'text-indent', - 'text-shadow', - 'text-transform', - 'white-space', - - 'height', - 'min-height', - 'max-height', - - 'width', - 'min-width', - 'max-width', - - 'margin', - 'margin-right', - 'margin-bottom', - 'margin-left', - 'margin-top', - 'margin-block-start', - 'margin-block-end', - 'margin-inline-start', - 'margin-inline-end', - - 'padding', - 'padding-right', - 'padding-bottom', - 'padding-left', - 'padding-top', - 'padding-block-start', - 'padding-block-end', - 'padding-inline-start', - 'padding-inline-end', - - 'flex', - 'flex-basis', - 'flex-direction', - 'flex-flow', - 'flex-grow', - 'flex-shrink', - 'flex-wrap', - - 'gap', - 'column-gap', - 'row-gap', - - 'grid-template-columns', - 'grid-auto-columns', - 'grid-column-start', - 'grid-column-end', - 'grid-column', - 'grid-column-gap', - 'grid-template-rows', - 'grid-auto-rows', - 'grid-row-start', - 'grid-row-end', - 'grid-row', - 'grid-row-gap', - 'grid-gap', - - 'justify-content', - 'justify-items', - 'justify-self', - 'align-content', - 'align-items', - 'align-self', - - 'clear', - 'cursor', - 'direction', - 'float', - 'list-style-type', - 'object-fit', - 'object-position', - 'opacity', - 'overflow', - 'vertical-align', - 'writing-mode', - - 'position', - 'top', - 'right', - 'bottom', - 'left', - 'z-index', - 'box-shadow', - 'aspect-ratio', - 'container-type', - - 'fill', - 'fill-opacity', - 'fill-rule', - - 'stroke', - 'stroke-dasharray', - 'stroke-dashoffset', - 'stroke-linecap', - 'stroke-linejoin', - 'stroke-miterlimit', - 'stroke-opacity', - 'stroke-width', - - 'color-interpolation', - 'color-interpolation-filters', - 'paint-order', - 'stop-color', - 'stop-opacity', - 'flood-color', - 'flood-opacity', - 'lighting-color', - - 'marker', - 'marker-end', - 'marker-mid', - 'marker-start', - - 'clip-path', - 'clip-rule', - 'mask', - 'mask-type', - - 'cx', - 'cy', - 'r', - 'rx', - 'ry', - 'x', - 'y', - 'd', - - 'alignment-baseline', - 'baseline-shift', - 'dominant-baseline', - 'glyph-orientation-horizontal', - 'glyph-orientation-vertical', - 'text-anchor', - 'unicode-bidi', - 'word-spacing', - - 'font-size-adjust', - 'font-stretch', - - 'color-rendering', - 'image-rendering', - 'shape-rendering', - 'text-rendering', - 'vector-effect', - - 'transform', - 'transform-origin', - - 'pointer-events', - 'visibility', - - 'anchor-name', - 'anchor-scope', - 'position-anchor', - 'position-area', - 'position-try', - 'position-try-fallbacks', - 'position-try-order', - 'position-visibility', - - // Custom CSS properties. - '--*', - ) - ); + $allowed_attr = apply_filters( 'safe_style_css', $default_allowed_attr ); + + if ( ! is_array( $allowed_attr ) ) { + $allowed_attr = $default_allowed_attr; + } + + // An empty list disables filtering. Both implementations return the input. + if ( empty( $allowed_attr ) ) { + return _safecss_filter_attr_legacy( $css, array() ); + } + + /** + * Filters whether to rely on the legacy parsing inside `safecss_filter_attr()`. + * + * The default implementation parses the declaration list with + * WP_HTML_Style_Attribute_Processor and filters each declaration. The + * legacy implementation splits the text on `;` and `:` and tests the + * pieces with regular expressions. It exists for a transition period so + * sites can restore the previous output while they adapt. + * + * @since 7.3.0 + * + * @param bool $force_legacy_css_parser Whether to force using the legacy implementation + * instead of relying on the CSS API. + */ + if ( apply_filters( 'wp_kses_force_legacy_css_parser', false ) ) { + return _safecss_filter_attr_legacy( $css, $allowed_attr ); + } + + return _safecss_filter_attr_declarations( $css, $allowed_attr ); +} + +/** + * Filters an inline style attribute by splitting it on `;` and `:`. + * + * This is the implementation safecss_filter_attr() used before 7.3.0. It + * runs when the `wp_kses_force_legacy_css_parser` filter returns true. + * + * @since 7.3.0 + * @access private + * @ignore + * @internal + * + * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. + * @param string[] $allowed_attr Allowed CSS property names after the `safe_style_css` filter. + * @return string Filtered string of CSS rules. + */ +function _safecss_filter_attr_legacy( $css, $allowed_attr ) { + $css = wp_kses_no_null( $css ); + $css = str_replace( array( "\n", "\r", "\t" ), '', $css ); + + $allowed_protocols = wp_allowed_protocols(); + + /** @todo Parse enough CSS to split rules without breaking on things like quoted strings. */ + $css_array = explode( ';', trim( $css ) ); /* * CSS attributes that accept URL data types. @@ -4120,6 +4176,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * Return true to allow the CSS part to be included in the output. * * @since 5.5.0 + * @deprecated 7.3.0 Applied only when the wp_kses_force_legacy_css_parser filter returns true. * * @param bool $allow_css Whether the CSS in the test string is considered safe. * @param string $css_test_string The CSS string to test. @@ -4140,6 +4197,329 @@ function safecss_filter_attr( $css, $deprecated = '' ) { return $css; } +/** + * Filters an inline style attribute by parsing it as a declaration list. + * + * Parses the list with WP_HTML_Style_Attribute_Processor, drops the + * declarations the policy rejects, and joins the + * WP_HTML_Style_Attribute_Processor::serialize_declaration() text of the + * rest, `property: value;` each, with a space. The unit of rejection is one + * declaration. + * Declarations CSS drops at parse time never reach the policy because + * the processor does not expose them. + * + * The policy: + * + * - The lowercased property name is in the allowed list, whose string + * entries are lowercased once and whose other entries are ignored, or the + * list contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. + * - Every function at any depth is in the function allowlist. + * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). + * - A non-custom property has no bare parenthesis block and no brace + * block at the top level of its value. + * - The value has no `&` delimiter at any depth. The output contains no + * `&`: other tokens escape it, and no property value uses a bare `&`. + * - The value has no `\` or control character delimiter at any depth. + * + * Checks run on decoded values. Comments are dropped; an empty comment is + * written only between two tokens that would otherwise read as other + * tokens. For an empty + * allowed list safecss_filter_attr() returns the input and does not call + * this function. + * + * A block (a function, `(`, `[` or `{`) or a comment, string or url token + * that the input ends inside is closed, as CSS closes it. + * + * @since 7.3.0 + * @access private + * @ignore + * @internal + * + * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. + * @param array $allowed_attr Allowed CSS property names after the `safe_style_css` filter. Must be non-empty. + * Entries that are not strings are ignored. + * @return string Filtered string of CSS rules. + */ +function _safecss_filter_attr_declarations( $css, $allowed_attr ) { + $css = (string) $css; + $allow_custom = in_array( '--*', $allowed_attr, true ); + $allowed_protocols = wp_allowed_protocols(); + + // Only string entries name properties. The processor lowercases property names; match the list the same way. + $allowed_attr = array_map( 'strtolower', array_filter( $allowed_attr, 'is_string' ) ); + + if ( has_filter( 'safecss_filter_attr_allow_css' ) ) { + _deprecated_hook( + 'safecss_filter_attr_allow_css', + '7.3.0', + '', + __( 'The hook only runs when the wp_kses_force_legacy_css_parser filter returns true.' ) + ); + } + + $processor = WP_HTML_Style_Attribute_Processor::create( $css ); + $declarations = array(); + + while ( $processor->next_declaration() ) { + $name = $processor->get_property_name(); + $tokens = $processor->get_value_tokens(); + $is_custom = str_starts_with( $name, '--' ); + + if ( $is_custom ) { + if ( ! $allow_custom || ! preg_match( '/^--[a-zA-Z0-9_-]+$/', $name ) ) { + continue; + } + } elseif ( ! in_array( $name, $allowed_attr, true ) ) { + continue; + } + + if ( ! _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is_custom ) ) { + continue; + } + + // The deprecated `safecss_filter_attr_allow_css` filter is applied by the legacy implementation only. + + $declarations[] = $processor->serialize_declaration(); + } + + return implode( ' ', $declarations ); +} + +/** + * Checks a declaration value's tokens against the function, URL, structure and delimiter policy. + * + * @since 7.3.0 + * @access private + * @ignore + * @internal + * + * @param WP_CSS_Token[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @param string[] $allowed_protocols Allowed URL protocols. + * @param bool $is_custom Whether the declaration is a custom property. + * @return bool Whether the value is allowed. + */ +function _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is_custom ) { + // A function is listed when its result depends only on its arguments and Blink, Gecko and WebKit ship it unprefixed. + static $allowed_functions = array( + // General purpose value functions. + 'var', + 'calc', + 'min', + 'max', + 'minmax', + 'clamp', + 'repeat', + // Math functions. + 'abs', + 'sign', + 'round', + 'mod', + 'rem', + 'pow', + 'sqrt', + 'hypot', + 'log', + 'exp', + 'sin', + 'cos', + 'tan', + 'asin', + 'acos', + 'atan', + 'atan2', + // Transform functions. + 'matrix', + 'matrix3d', + 'perspective', + 'rotate', + 'rotate3d', + 'rotatex', + 'rotatey', + 'rotatez', + 'scale', + 'scale3d', + 'scalex', + 'scaley', + 'scalez', + 'skew', + 'skewx', + 'skewy', + 'translate', + 'translate3d', + 'translatex', + 'translatey', + 'translatez', + // Basic shape functions. + 'circle', + 'ellipse', + 'inset', + 'path', + 'polygon', + 'rect', + 'shape', + 'xywh', + // Gradients. + 'linear-gradient', + 'radial-gradient', + 'conic-gradient', + 'repeating-linear-gradient', + 'repeating-radial-gradient', + 'repeating-conic-gradient', + // Color functions. + 'rgb', + 'rgba', + 'hsl', + 'hsla', + 'hwb', + 'lab', + 'lch', + 'oklab', + 'oklch', + 'color', + 'color-mix', + 'light-dark', + // Filter functions. + 'blur', + 'brightness', + 'contrast', + 'drop-shadow', + 'grayscale', + 'hue-rotate', + 'invert', + 'opacity', + 'saturate', + 'sepia', + // Easing functions. + 'cubic-bezier', + 'steps', + 'linear', + ); + + $count = count( $tokens ); + $depth = 0; + for ( $i = 0; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + $type = $token->get_type(); + + switch ( $type ) { + case WP_CSS_Token_Processor::TOKEN_DELIM: + $value = $token->get_value(); + // The output contains no `&`: other tokens escape it, and no property value uses a bare `&`. + if ( '&' === $value ) { + return false; + } + // No property value uses a `\` or a control character as a delimiter. + if ( '\\' === $value || ord( $value ) < 0x20 || "\x7F" === $value ) { + return false; + } + continue 2; + + case WP_CSS_Token_Processor::TOKEN_URL: + if ( ! _safecss_filter_attr_url_is_allowed( $token->get_value(), $allowed_protocols ) ) { + return false; + } + continue 2; + + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + // No standard property accepts a bare `( )` or `{ }` block as a top-level value. + if ( ! $is_custom && 0 === $depth ) { + return false; + } + ++$depth; + continue 2; + + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: + // `[ ]` is valid at the top level, as in `grid-template-columns: [a] 1fr`. + ++$depth; + continue 2; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: + --$depth; + continue 2; + + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + ++$depth; + break; + + default: + continue 2; + } + + $function_name = strtolower( (string) $token->get_value() ); + + if ( 'url' === $function_name ) { + // `url("...")` tokenizes as a function with one string argument, closed by `)` or the end of the input. Anything else inside is invalid CSS. + $j = $i + 1; + while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { + ++$j; + } + if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_STRING !== $tokens[ $j ]->get_type() ) { + return false; + } + $string = $tokens[ $j ]; + ++$j; + while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { + ++$j; + } + if ( $j < $count && WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN !== $tokens[ $j ]->get_type() ) { + return false; + } + if ( ! _safecss_filter_attr_url_is_allowed( $string->get_value(), $allowed_protocols ) ) { + return false; + } + // The `)` is consumed with the argument. + --$depth; + $i = $j; + continue; + } + + if ( ! in_array( $function_name, $allowed_functions, true ) ) { + return false; + } + } + + return true; +} + +/** + * Checks a decoded URL against the allowed protocols. + * + * @since 7.3.0 + * @access private + * @ignore + * @internal + * + * @param string|null $url Decoded URL. + * @param string[] $allowed_protocols Allowed URL protocols. + * @return bool Whether the URL is non-empty and unchanged by wp_kses_bad_protocol(). + */ +function _safecss_filter_attr_url_is_allowed( $url, $allowed_protocols ) { + $url = trim( (string) $url ); + if ( '' === $url ) { + return false; + } + + return wp_kses_bad_protocol( $url, $allowed_protocols ) === $url; +} + +/** + * Checks whether a token is whitespace or a comment. + * + * @since 7.3.0 + * @access private + * @ignore + * @internal + * + * @param WP_CSS_Token $token Token from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @return bool Whether the token is whitespace or a comment. + */ +function _safecss_filter_attr_is_trivia( $token ) { + return WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token->get_type() || WP_CSS_Token_Processor::TOKEN_COMMENT === $token->get_type(); +} + /** * Helper function to add global attributes to a tag in the allowed HTML list. * diff --git a/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php b/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php index 797947d1e9e75..129b14bb7b0cc 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php +++ b/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php @@ -199,8 +199,22 @@ protected static function filter_declaration( $property, $value, $spacer = '', $ $filtered_declaration = safecss_filter_attr( "{$property}:{$spacer}{$filtered_value}" ); + // safecss_filter_attr() may end each declaration with `;`. The caller adds its own. + if ( str_ends_with( $filtered_declaration, ';' ) ) { + $filtered_declaration = substr( $filtered_declaration, 0, -1 ); + } + + if ( '' === $filtered_declaration ) { + return ''; + } + + // safecss_filter_attr() writes its own space after the colon. Apply the spacer. + if ( str_starts_with( $filtered_declaration, "{$property}:" ) ) { + $filtered_declaration = "{$property}:{$spacer}" . ltrim( substr( $filtered_declaration, strlen( $property ) + 1 ) ); + } + // Only append !important in the presence of an option value and when sanitization returns a single declaration. - if ( true === $options['important'] && '' !== $filtered_declaration && ! str_contains( $filtered_declaration, ';' ) ) { + if ( true === $options['important'] && ! str_contains( $filtered_declaration, ';' ) ) { return "$filtered_declaration !important"; } 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..321ca42fd1b84 --- /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 = WP_CSS_Token_Processor::create( $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..5f052cc6af272 --- /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 = WP_CSS_Token_Processor::create( '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 = WP_CSS_Token_Processor::create( '\\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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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..8e9b2b1d1ba1b --- /dev/null +++ b/tests/phpunit/tests/css-api/wpCssTokenProcessor.php @@ -0,0 +1,2642 @@ +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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( "'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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( '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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( '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 = WP_CSS_Token_Processor::create( "'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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( "'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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 that create() validates encoding and only accepts UTF-8. + */ + public function test_create_validates_encoding(): void { + // UTF-8 encoding should work (default). + $processor = WP_CSS_Token_Processor::create( '.class { color: red; }' ); + $this->assertInstanceOf( WP_CSS_Token_Processor::class, $processor ); + + // UTF-8 encoding should work (explicit). + $processor = WP_CSS_Token_Processor::create( '.class { color: red; }', 'UTF-8' ); + $this->assertInstanceOf( WP_CSS_Token_Processor::class, $processor ); + + // Other encodings should return null. + $processor = WP_CSS_Token_Processor::create( '.class { color: red; }', 'ISO-8859-1' ); + $this->assertNull( $processor ); + + $processor = WP_CSS_Token_Processor::create( '.class { color: red; }', 'Windows-1252' ); + $this->assertNull( $processor ); + } + + /** + * 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 = WP_CSS_Token_Processor::create( '"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 = WP_CSS_Token_Processor::create( '"\\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..22a9d31a3f3a7 --- /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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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 = WP_CSS_Token_Processor::create( $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..5854b71a03f37 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlStyleAttributeProcessor.php @@ -0,0 +1,1387 @@ +assertSame( 'color: red', $processor->get_updated_style() ); + } + + /** + * @covers ::create + * + * @dataProvider data_non_string_style_attribute_values + * + * @param mixed $style_attribute_value Non-string style attribute value. + */ + public function test_create_rejects_non_string_style_attribute_values( $style_attribute_value ) { + $this->expectException( TypeError::class ); + + WP_HTML_Style_Attribute_Processor::create( $style_attribute_value ); + } + + /** + * @coversNothing + */ + public function test_constructor_is_not_public() { + $reflection = new ReflectionClass( WP_HTML_Style_Attribute_Processor::class ); + $constructor = $reflection->getConstructor(); + + $this->assertNotNull( $constructor ); + $this->assertFalse( $constructor->isPublic() ); + } + + /** + * @coversNothing + */ + public function test_raw_value_getter_is_not_public_api() { + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_value' ) ); + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_raw_value' ) ); + $this->assertFalse( method_exists( WP_HTML_Style_Attribute_Processor::class, 'get_value_source' ) ); + } + + /** + * @covers ::next_declaration + * @covers ::get_property_name + * @covers ::is_important + */ + public function test_next_declaration_reads_properties_in_order_and_preserves_duplicates() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'COLOR: red; color: blue !important; background: white' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertFalse( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + + $this->assertFalse( $processor->next_declaration() ); + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); + } + + /** + * @covers ::next_declaration + * @covers ::get_property_name + */ + public function test_next_declaration_query_visits_matching_duplicate_properties() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white; COLOR: blue;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'color', $processor->get_property_name() ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'color', $processor->get_property_name() ); + + $this->assertFalse( $processor->next_declaration( 'color' ) ); + } + + /** + * @covers ::next_declaration + * @covers ::get_property_name + */ + public function test_custom_property_queries_match_exact_decoded_casing() { + $processor = WP_HTML_Style_Attribute_Processor::create( '--Tone: warm; --tone: cool;' ); + + $this->assertTrue( $processor->next_declaration( '--tone' ) ); + $this->assertSame( '--tone', $processor->get_property_name() ); + + $this->assertFalse( $processor->next_declaration( '--tone' ) ); + } + + /** + * @covers ::next_declaration + */ + public function test_next_declaration_accepts_named_property_name_argument() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + + /** + * @covers ::is_important + * + * @dataProvider data_important_priority_syntax + * + * @param string $style Style attribute value. + * @param bool $important Expected importance. + */ + public function test_important_priority_syntax_variants( string $style, bool $important ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $important, $processor->is_important() ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + */ + public function test_get_value_tokens_returns_null_when_not_on_a_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertNull( $processor->get_value_tokens(), 'Expected null before the first declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertNotNull( $processor->get_value_tokens() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + + $this->assertNull( $processor->get_value_tokens(), 'Expected null after the last declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * + * @dataProvider data_value_tokens + * + * @param string $style Style text. + * @param array $expected_tokens Expected value tokens. + */ + public function test_get_value_tokens_covers_only_the_declaration_value( string $style, array $expected_tokens ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $tokens = $processor->get_value_tokens(); + + $this->assertContainsOnlyInstancesOf( WP_CSS_Token::class, $tokens ); + $this->assertSame( $expected_tokens, array_map( array( self::class, 'token_fields' ), $tokens ) ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + */ + public function test_get_value_tokens_decodes_values_and_keeps_source_offsets() { + $style = 'content: "a\\"b"'; + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $tokens = $processor->get_value_tokens(); + + $this->assertCount( 1, $tokens ); + $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $tokens[0]->get_type() ); + $this->assertSame( 'a"b', $tokens[0]->get_value() ); + $this->assertSame( '"a\\"b"', substr( $style, $tokens[0]->get_start(), $tokens[0]->get_length() ) ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * @covers ::set_value + * @covers ::remove_declaration + */ + public function test_get_value_tokens_follow_the_updated_style_after_mutations() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertSame( + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'green', 7, 5 ) ), + array_map( array( self::class, 'token_fields' ), $processor->get_value_tokens() ) + ); + + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->get_value_tokens() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background: white', $processor->get_updated_style() ); + $this->assertSame( + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'white', 12, 5 ) ), + array_map( array( self::class, 'token_fields' ), $processor->get_value_tokens() ) + ); + } + + /** + * @ticket 65738 + * + * @covers ::get_value_tokens + * + * @dataProvider data_value_tokens_source_slices + * + * @param string $style Style text. + */ + public function test_get_value_tokens_match_the_retokenized_source_slice( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $tokens = $processor->get_value_tokens(); + $this->assertNotEmpty( $tokens ); + + $first = $tokens[0]; + $last = $tokens[ count( $tokens ) - 1 ]; + $slice = substr( $style, $first->get_start(), $last->get_start() + $last->get_length() - $first->get_start() ); + + $expected = array(); + $tokenizer = WP_CSS_Token_Processor::create( $slice ); + while ( $tokenizer->next_token() ) { + $token = $tokenizer->get_token(); + $expected[] = array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->is_terminated() ); + } + + $actual = array_map( + static function ( WP_CSS_Token $token ): array { + return array( $token->get_type(), $token->get_flag(), $token->get_value(), $token->get_unit(), $token->is_terminated() ); + }, + $tokens + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * @covers ::remove_declaration + */ + public function test_serialize_declaration_returns_null_when_not_on_a_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertNull( $processor->serialize_declaration(), 'Expected null before the first declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->serialize_declaration(), 'Expected null after removing the declaration.' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertNull( $processor->serialize_declaration(), 'Expected null after the last declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * + * @dataProvider data_serialized_declarations + * + * @param string $style Style text. + * @param string $expected Expected text of the first declaration. + */ + public function test_serialize_declaration_writes_the_current_declaration( string $style, string $expected ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( $expected, $processor->serialize_declaration() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_serialized_declarations(): array { + return array( + 'single ident' => array( 'color: red', 'color: red;' ), + 'important' => array( 'color : red ! important ;', 'color: red !important;' ), + 'important after a comment' => array( 'color: red /* x */ !important', 'color: red !important;' ), + 'lowercased name' => array( 'COLOR: Red', 'color: Red;' ), + 'escaped name' => array( 'c\\6f lor: red', 'color: red;' ), + 'custom property keeps its case' => array( '--Foo: a b', '--Foo: a b;' ), + 'custom property name escape' => array( '--a\\ b: 1', '--a\\20 b: 1;' ), + 'comments and whitespace runs' => array( "margin: 1px /* a */ \t 2px", 'margin: 1px 2px;' ), + 'comment between tokens' => array( 'margin: 1px/**/2px', 'margin: 1px/**/2px;' ), + 'empty value' => array( 'color:;', 'color: ;' ), + 'string' => array( "font-family: 'a\"b'", 'font-family: "a\\22 b";' ), + 'value ending in an escaped space' => array( '--x: a\\ ', '--x: a\\20 ;' ), + 'backslash before a newline' => array( "--x: a\\\n; width: 1px", "--x: a\\\n;" ), + 'block open at the end' => array( 'width: calc(1px + (2px', 'width: calc(1px + (2px));' ), + 'url open at the end' => array( 'background: url(a.png', 'background: url(a.png);' ), + 'semicolon inside an open block' => array( 'width: calc(1px; color: red', 'width: calc(1px; color: red);' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * + * @dataProvider data_value_tokens_source_slices + * + * @param string $style Style text. + */ + public function test_serialize_declaration_reads_back_as_the_same_declaration( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + $this->assertTrue( $processor->next_declaration() ); + + $reread = WP_HTML_Style_Attribute_Processor::create( $processor->serialize_declaration() ); + $this->assertTrue( $reread->next_declaration() ); + + $this->assertSame( $processor->get_property_name(), $reread->get_property_name() ); + $this->assertSame( $processor->is_important(), $reread->is_important() ); + $this->assertSame( + self::significant_tokens( $processor->get_value_tokens() ), + self::significant_tokens( $reread->get_value_tokens() ) + ); + $this->assertFalse( $reread->next_declaration(), 'Expected exactly one declaration.' ); + } + + /** + * @ticket 65738 + * + * @covers ::serialize_declaration + * @covers ::set_value + */ + public function test_serialize_declaration_follows_mutations() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( 'green /* c */', true ) ); + $this->assertSame( 'color: green !important;', $processor->serialize_declaration() ); + } + + /** + * Reads the fields that give a value its meaning, closing blocks left open. + * + * @param WP_CSS_Token[] $tokens Value tokens. + * @return array[] Type, flag, value and unit of each token except whitespace and comments. + */ + private static function significant_tokens( array $tokens ): array { + $closers = array( + WP_CSS_Token_Processor::TOKEN_FUNCTION => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_PAREN => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACE => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE, + ); + $open = array(); + $fields = array(); + + foreach ( $tokens as $token ) { + $type = $token->get_type(); + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type || WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) { + continue; + } + + if ( isset( $closers[ $type ] ) ) { + $open[] = $closers[ $type ]; + } elseif ( in_array( $type, $closers, true ) ) { + array_pop( $open ); + } + + $fields[] = array( $type, $token->get_flag(), $token->get_value(), $token->get_unit() ); + } + + foreach ( array_reverse( $open ) as $type ) { + $fields[] = array( $type, null, null, null ); + } + + return $fields; + } + + /** + * @covers ::set_value + * @covers ::get_updated_style + */ + public function test_set_value_updates_current_declaration_without_collapsing_duplicates() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red !important; color: color(display-p3 1 0 0);' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green', false ) ); + + $this->assertSame( 'color: green; color: color(display-p3 1 0 0);', $processor->get_updated_style() ); + } + + /** + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_declaration_values_allow_leading_and_trailing_comments_as_trivia() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( '/* before */ green /* after */' ) ); + $this->assertTrue( $processor->append_declaration( 'background', '/* before */ white /* after */' ) ); + + $this->assertSame( 'color: green; background: white;', $processor->get_updated_style() ); + } + + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_values_ending_in_escaped_whitespace_or_a_backslash_delimiter + * + * @param string $value Declaration value. + * @param string $written Expected value text in the updated style. + */ + public function test_trimming_a_value_keeps_its_final_escape_or_backslash_delimiter( string $value, string $written ) { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: 1; width: 1px' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( $value ), 'Expected set_value() to succeed.' ); + $this->assertSame( "--x: {$written}; width: 1px", $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: 1px' ); + + $this->assertTrue( $processor->append_declaration( '--x', $value ), 'Expected append_declaration() to succeed.' ); + $this->assertSame( "width: 1px; --x: {$written};", $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_values_ending_in_escaped_whitespace_or_a_backslash_delimiter() { + return array( + 'escaped space' => array( 'a\\ ', 'a\\20 ' ), + 'escaped space, then whitespace' => array( "a\\ \t", 'a\\20 ' ), + 'escaped tab' => array( "a\\\t", 'a\\9 ' ), + 'backslash before a newline' => array( "a\\\n", "a\\\n" ), + 'backslash, newline, then spaces' => array( "a\\\n ", "a\\\n" ), + 'escaped backslash' => array( 'a\\\\ ', 'a\\5c ' ), + 'escaped backslash, then a delim' => array( "a\\\\\\\n", "a\\5c \\\n" ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_values_written_from_tokens + * + * @param string $value Declaration value. + * @param string $written Expected value text in the updated style. + */ + public function test_set_value_and_append_declaration_write_the_value_from_its_tokens( string $value, string $written ) { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: 1; width: 1px' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( $value ), 'Expected set_value() to succeed.' ); + $this->assertSame( "--x: {$written}; width: 1px", $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: 1px' ); + + $this->assertTrue( $processor->append_declaration( '--x', $value ), 'Expected append_declaration() to succeed.' ); + $this->assertSame( "width: 1px; --x: {$written};", $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_values_written_from_tokens() { + return array( + 'comments and whitespace runs' => array( " \t/* a */ a \t/* b */ b /* c */ ", 'a b' ), + 'comment between tokens' => array( 'a/**/b', 'a/**/b' ), + 'escaped ident' => array( '\\66 oo', 'foo' ), + 'single-quoted string' => array( "'a'", '"a"' ), + 'control character in a string' => array( "\"a\x01b\"", '"a\\1 b"' ), + 'url with spaces and ampersand' => array( 'url( a&b )', 'url(a\\26 b)' ), + 'non-ident code point in ident' => array( "a\u{A0}b", 'a\\a0 b' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_set_value_and_append_declaration_write_the_name_as_an_identifier() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'C\\6f LOR: red' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_value( 'blue' ) ); + $this->assertSame( 'color: blue;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertTrue( $processor->append_declaration( "--a\u{A0}b", '1' ) ); + $this->assertSame( '--a\\a0 b: 1;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_value + * @covers ::is_important + */ + public function test_set_value_preserves_sets_and_clears_importance() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red !important; background: white; border-color: black;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_value( 'blue', true ) ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration( 'border-color' ) ); + $this->assertTrue( $processor->set_value( 'currentColor', false ) ); + $this->assertFalse( $processor->is_important() ); + + $this->assertSame( + 'color: green !important; background: blue !important; border-color: currentColor;', + $processor->get_updated_style() + ); + } + + /** + * @covers ::set_value + */ + public function test_set_value_rejects_empty_value() { + $processor = WP_HTML_Style_Attribute_Processor::create( '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 = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( " \t\n\r\f" ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::set_important + * @covers ::is_important + * @covers ::get_updated_style + */ + public function test_set_important_sets_and_clears_priority() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white ! /*x*/ important;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( false ) ); + $this->assertFalse( $processor->is_important() ); + + $this->assertSame( 'color: red !important; background: white ;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_important + * @covers ::get_updated_style + */ + public function test_set_important_can_repair_unclosed_eof_component_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x /*c*/' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x /*c*/) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x /*c*/ ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'color: var(--x /*c*/ ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo ' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo ) !important', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo\\)' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: url(foo\\)) !important', $processor->get_updated_style() ); + } + + /** + * @covers ::set_important + */ + public function test_set_important_returns_false_without_current_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertFalse( $processor->set_important( true ) ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_removes_only_current_duplicate() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; color: blue; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_removes_adjacent_duplicate_declarations() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; color: blue; background: white;' ); + + while ( $processor->next_declaration( 'color' ) ) { + $this->assertTrue( $processor->remove_declaration() ); + } + + $this->assertSame( 'background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_property_name + * @covers ::is_important + * @covers ::get_value_tokens + */ + public function test_getters_return_null_after_removing_current_declaration_until_cursor_advances() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); + $this->assertNull( $processor->get_value_tokens() ); + $this->assertFalse( $processor->remove_declaration() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_preserves_surrounding_comments() { + $processor = WP_HTML_Style_Attribute_Processor::create( '/*keep*/ color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; /*keep*/ background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( '/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; /*keep*/ background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'background' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( 'color: red; /*keep*/', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::get_updated_style + */ + public function test_remove_declaration_with_invalid_fragments_preserves_remaining_structure() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; invalid; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertSame( 'invalid; background: white;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_adds_duplicate_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'color(display-p3 1 0 0)', true ) ); + + $this->assertSame( 'color: red; color: color(display-p3 1 0 0) !important;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_preserves_multiple_appends_in_order() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_property_name + * @covers ::get_updated_style + * @covers ::is_important + * @covers ::next_declaration + * @covers ::remove_declaration + * @covers ::set_important + * @covers ::set_value + */ + public function test_mixed_mutations_preserve_the_logical_cursor_and_declaration_order() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->set_value( 'green', true ) ); + $this->assertTrue( $processor->set_important( false ) ); + $this->assertTrue( $processor->append_declaration( 'border', '1px', true ) ); + + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertFalse( $processor->is_important() ); + $this->assertTrue( $processor->set_value( 'blue' ) ); + $this->assertSame( 'color: blue; background: white; border: 1px !important;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->remove_declaration() ); + $this->assertNull( $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'border', $processor->get_property_name() ); + $this->assertTrue( $processor->is_important() ); + $this->assertSame( 'background: white; border: 1px !important;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_treats_comments_as_trivia_for_separators() { + $processor = WP_HTML_Style_Attribute_Processor::create( '/*keep*/' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( '/*keep*/ color: red;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;/*keep*/' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: red;/*keep*/ background: white;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x;/*keep*/);' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: var(--x;/*keep*/); background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_lowercases_ordinary_properties_and_preserves_custom_properties() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertTrue( $processor->append_declaration( 'BackgroundColor', 'red' ) ); + $this->assertTrue( $processor->append_declaration( '--Tone', 'warm' ) ); + + $this->assertSame( 'backgroundcolor: red; --Tone: warm;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::next_declaration + * @covers ::get_property_name + */ + public function test_appended_declarations_can_be_inspected_by_the_cursor() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::append_declaration + * @covers ::next_declaration + * @covers ::get_property_name + * @covers ::set_value + */ + public function test_append_declaration_preserves_exhausted_cursor_until_it_advances() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertNull( $processor->get_property_name() ); + $this->assertNull( $processor->is_important() ); + $this->assertFalse( $processor->set_value( 'green' ) ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertSame( 'color: red; background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::remove_declaration + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_after_removing_only_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red; ' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertTrue( $processor->remove_declaration() ); + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + + $this->assertSame( 'background: white;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_repairs_unclosed_eof_component_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x' ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( 'color: var(--x); background: white;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'width: calc(1px + var(--gap' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( 'width: calc(1px + var(--gap)); color: red;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo' ); + + $this->assertTrue( $processor->append_declaration( 'color', 'red' ) ); + $this->assertSame( 'background: url(foo); color: red;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo ' ); + + $this->assertTrue( $processor->append_declaration( 'border', '0' ) ); + $this->assertSame( 'background: url(foo ); border: 0;', $processor->get_updated_style() ); + + $processor = WP_HTML_Style_Attribute_Processor::create( 'background: url(foo\\)' ); + + $this->assertTrue( $processor->append_declaration( 'border', '0' ) ); + $this->assertSame( 'background: url(foo\\)); border: 0;', $processor->get_updated_style() ); + } + + /** + * @ticket 65738 + * + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_styles_ending_inside_a_string_or_comment + * + * @param string $style Style text. + * @param string $expected Updated style text. + */ + public function test_append_declaration_closes_strings_and_comments_left_open( string $style, string $expected ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( $expected, $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_styles_ending_inside_a_string_or_comment(): array { + return array( + 'comment inside an open function' => array( 'color: var(--x;/*keep', 'color: var(--x;/*keep*/); background: white;' ), + 'comment after a declaration' => array( 'color: red /* keep', 'color: red /* keep*/; background: white;' ), + 'comment ending in a slash' => array( 'color: red /*/', 'color: red /*/*/; background: white;' ), + 'comment ending in a backslash' => array( 'color: red /* \\', 'color: red /* \\*/; background: white;' ), + 'double-quoted string' => array( 'font-family: "a', 'font-family: "a"; background: white;' ), + 'single-quoted string' => array( "font-family: 'a", "font-family: 'a'; background: white;" ), + 'string inside an open function' => array( 'width: var(--x, "a', 'width: var(--x, "a"); background: white;' ), + ); + } + + /** + * @ticket 65738 + * + * @covers ::set_important + * @covers ::get_updated_style + */ + public function test_set_important_closes_a_string_left_open() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'font-family: "a' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertTrue( $processor->set_important( true ) ); + $this->assertSame( 'font-family: "a" !important', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + * + * @dataProvider data_styles_ending_in_a_backslash + * + * @param string $style Style text. + */ + public function test_append_declaration_rejects_eof_repairs_that_cannot_be_precise( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertFalse( $processor->append_declaration( 'background', 'white' ) ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_styles_ending_in_a_backslash(): array { + return array( + 'backslash after an ident' => array( 'color: x\\' ), + 'backslash inside a string' => array( 'content: "a\\' ), + ); + } + + /** + * @covers ::next_declaration + * @covers ::get_updated_style + */ + public function test_invalid_fragments_are_skipped_and_preserved() { + $style = 'color red; @media (min-width: 1px) { color: green; } background: white;'; + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + + /** + * @covers ::next_declaration + */ + public function test_stray_right_brace_consumes_bad_declaration_until_semicolon() { + $processor = WP_HTML_Style_Attribute_Processor::create( '} color: red; background: white;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'background', $processor->get_property_name() ); + $this->assertFalse( $processor->next_declaration() ); + } + + /** + * @covers ::next_declaration + * @covers ::get_updated_style + * + * @dataProvider data_declarations_with_values_css_ignores + * + * @param string $style Style attribute value whose first declaration CSS ignores. + */ + public function test_declarations_with_values_css_ignores_are_skipped_and_preserved( string $style ) { + $processor = WP_HTML_Style_Attribute_Processor::create( $style ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertFalse( $processor->next_declaration() ); + $this->assertSame( $style, $processor->get_updated_style() ); + } + + /** + * @covers ::next_declaration + */ + public function test_custom_property_values_may_mix_blocks_with_other_tokens() { + $processor = WP_HTML_Style_Attribute_Processor::create( '--x: { a } b; color: red;' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( '--x', $processor->get_property_name() ); + $this->assertTrue( $processor->next_declaration() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::next_declaration + * @covers ::set_value + * @covers ::get_updated_style + */ + public function test_escaped_property_names_match_decoded_names_and_are_written_as_identifiers() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'c\\6f lor: red; --t\\6f ne: cool; --Tone: warm;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertSame( 'color', $processor->get_property_name() ); + $this->assertTrue( $processor->set_value( 'green' ) ); + $this->assertSame( 'color: green; --t\\6f ne: cool; --Tone: warm;', $processor->get_updated_style() ); + + $this->assertTrue( $processor->next_declaration( '--tone' ) ); + $this->assertSame( '--tone', $processor->get_property_name() ); + $this->assertTrue( $processor->set_value( 'cold' ) ); + $this->assertSame( 'color: green; --tone: cold; --Tone: warm;', $processor->get_updated_style() ); + + $this->assertFalse( $processor->next_declaration( '--tone' ) ); + } + + /** + * @covers ::is_important + */ + public function test_important_inside_unclosed_function_is_not_declaration_priority() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: var(--x, red !important' ); + + $this->assertTrue( $processor->next_declaration() ); + $this->assertFalse( $processor->is_important() ); + } + + /** + * @covers ::set_value + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_declaration_values_reject_top_level_semicolons_and_important_priority() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( 'blue; color: green' ) ); + $this->assertFalse( $processor->set_value( 'green ! important', false ) ); + $this->assertFalse( $processor->set_value( 'green ! important foo', false ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'blue; color: green' ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'white !important' ) ); + $this->assertFalse( $processor->append_declaration( 'background', 'white !important foo' ) ); + + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_append_declaration_rejects_empty_values() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertFalse( $processor->append_declaration( 'background', '' ) ); + $this->assertFalse( $processor->append_declaration( 'background', '/* comment */' ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::set_value + * @covers ::get_updated_style + */ + public function test_set_value_rejects_comment_only_values_without_removing_declaration() { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( '/* comment */' ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + $this->assertSame( 'color', $processor->get_property_name() ); + } + + /** + * @covers ::append_declaration + * @covers ::set_value + * @covers ::get_updated_style + * + * @dataProvider data_malformed_declaration_values + * + * @param string $value Malformed CSS declaration value. + */ + public function test_declaration_values_reject_malformed_eof_tokens( string $value ) { + $processor = WP_HTML_Style_Attribute_Processor::create( 'color: red;' ); + + $this->assertTrue( $processor->next_declaration( 'color' ) ); + $this->assertFalse( $processor->set_value( $value ) ); + $this->assertFalse( $processor->append_declaration( 'background', $value ) ); + $this->assertSame( 'color: red;', $processor->get_updated_style() ); + } + + /** + * @covers ::append_declaration + * @covers ::get_updated_style + */ + public function test_property_names_reject_escaped_source_identifiers() { + $processor = WP_HTML_Style_Attribute_Processor::create( '' ); + + $this->assertFalse( $processor->append_declaration( 'color\\', 'red' ) ); + $this->assertFalse( $processor->append_declaration( 'c\\6f lor', 'red' ) ); + $this->assertSame( '', $processor->get_updated_style() ); + } + + /** + * @coversNothing + */ + public function test_whitespace_constant_is_not_public_api() { + $reflection = new ReflectionClass( WP_HTML_Style_Attribute_Processor::class ); + + $this->assertFalse( $reflection->hasConstant( 'WHITESPACE' ) && $reflection->getReflectionConstant( 'WHITESPACE' )->isPublic() ); + } + + /** + * @covers ::get_updated_style + */ + public function test_updated_style_can_be_set_on_html_tag_processor_after_string_check() { + $tags = new WP_HTML_Tag_Processor( '
Text
' ); + $this->assertTrue( $tags->next_tag( 'div' ) ); + + $style = $tags->get_attribute( 'style' ); + $this->assertIsString( $style ); + + $styles = WP_HTML_Style_Attribute_Processor::create( $style ); + $this->assertTrue( $styles->append_declaration( 'background', 'white' ) ); + $this->assertTrue( $tags->set_attribute( 'style', $styles->get_updated_style() ) ); + + $this->assertSame( + '
Text
', + $tags->get_updated_html() + ); + } + + /** + * Builds the expected fields of a value token. + * + * @param string $type Token type. + * @param string|null $value Token value. + * @param int $start Byte offset of the token. + * @param int $length Byte length of the token. + * @param string|null $unit Optional. Dimension unit. Default null. + * @return array{type:string, value:string|null, unit:string|null, start:int, length:int} + */ + private static function token( string $type, ?string $value, int $start, int $length, ?string $unit = null ): array { + return array( + 'type' => $type, + 'value' => $value, + 'unit' => $unit, + 'start' => $start, + 'length' => $length, + ); + } + + /** + * Reads the fields of a value token that the expectations compare. + * + * @param WP_CSS_Token $token Value token. + * @return array{type:string, value:string|null, unit:string|null, start:int, length:int} + */ + private static function token_fields( WP_CSS_Token $token ): array { + return self::token( $token->get_type(), $token->get_value(), $token->get_start(), $token->get_length(), $token->get_unit() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_value_tokens(): array { + return array( + 'single ident' => array( + 'color: red', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'function with nested tokens' => array( + 'width: calc(1px + 2%)', + array( + self::token( WP_CSS_Token_Processor::TOKEN_FUNCTION, 'calc', 7, 5 ), + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 12, 3, 'px' ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 15, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_DELIM, '+', 16, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 17, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_PERCENTAGE, '2', 18, 2 ), + self::token( WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, null, 20, 1 ), + ), + ), + 'hash without number sign' => array( + 'color: #fff', + array( self::token( WP_CSS_Token_Processor::TOKEN_HASH, 'fff', 7, 4 ) ), + ), + 'important excluded' => array( + 'color: red !important', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'important without space excluded' => array( + 'color: red!important;', + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 7, 3 ) ), + ), + 'leading and trailing trivia' => array( + "color: /* a */ \t red /* b */ ;", + array( self::token( WP_CSS_Token_Processor::TOKEN_IDENT, 'red', 17, 3 ) ), + ), + 'trivia inside the value kept' => array( + 'margin: 1px /* a */ 2px', + array( + self::token( WP_CSS_Token_Processor::TOKEN_DIMENSION, '1', 8, 3, 'px' ), + self::token( WP_CSS_Token_Processor::TOKEN_WHITESPACE, null, 11, 1 ), + self::token( WP_CSS_Token_Processor::TOKEN_COMMENT, ' 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( + 'missing style attribute' => array( null ), + 'boolean style attribute' => array( true ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_important_priority_syntax(): array { + return array( + 'no whitespace' => array( 'color: red!important;', true ), + 'whitespace after bang' => array( 'color: red ! important;', true ), + 'comment after bang' => array( 'color: red ! /*x*/ important;', true ), + 'escaped important' => array( 'color: red !\\69mportant;', true ), + 'nested important' => array( 'color: var(--x, red !important);', false ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_declarations_with_values_css_ignores(): array { + return array( + 'bad string' => array( "font-family: \"a\n; color: red;" ), + 'bad url' => array( 'background: url(a b); color: red;' ), + 'nested bad url' => array( 'background: image-set(url(a b) 1x); color: red;' ), + 'unmatched right paren' => array( 'width: 1px ); color: red;' ), + 'unmatched right bracket' => array( 'width: 1px ]; color: red;' ), + 'unmatched right brace' => array( 'width: 1px }; color: red;' ), + 'mismatched closer in function' => array( 'width: calc(1px } + 2px); color: red;' ), + 'custom property unmatched brace' => array( '--x: a } b; color: red;' ), + 'bang before important' => array( 'padding: 0 ! 1px !important; color: red;' ), + 'important before other tokens' => array( 'padding: 0 !important 1px; color: red;' ), + 'custom property bang' => array( '--x: a ! b; color: red;' ), + 'block mixed with other tokens' => array( 'width: 1px { x }; color: red;' ), + 'block before other tokens' => array( 'width: { x } 1px; color: red;' ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_malformed_declaration_values(): array { + return array( + 'eof string' => array( '"unterminated' ), + 'eof string escaped end' => array( '"unterminated\\' ), + 'eof string apparent end escaped' => array( '"unterminated\\"' ), + 'eof comment' => array( '/*' ), + 'eof url' => array( 'url(foo' ), + 'eof url escaped end' => array( 'url(foo\\' ), + 'eof url apparent end escaped' => array( 'url(foo\\)' ), + 'eof escape' => array( 'red\\' ), + ); + } +} diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 74a136569ad66..ddd35bda86564 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -969,25 +969,30 @@ public function data_slash_zero_removal() { '
This should be no big deal.
', '
This should be no big deal.
', ), + /* + * In a style attribute the CSS escapes are decoded before the checks and + * re-serialized: `\0` is U+FFFD and `\\` is a literal backslash. The + * value is what a browser reads, not `left`. + */ array( 'This
is more of a concern.', - 'This
is more of a concern.', + "This
is more of a concern.", ), array( 'This
is more of a concern.', - 'This
is more of a concern.', + "This
is more of a concern.", ), array( 'This
is more of a concern.', - 'This
is more of a concern.', + 'This
is more of a concern.', ), array( 'This
is more of a concern.', - 'This
is more of a concern.', + 'This
is more of a concern.', ), array( 'This
is more of a concern.', - 'This
is more of a concern.', + "This
is more of a concern.", ), array( '', @@ -1156,15 +1161,16 @@ public function data_one_attr() { 'href="javascript:alert(1)"', 'href="alert(1)"', ), + // A style value with no declaration is dropped. array( 'a', 'style ="style "', - 'style="style"', + '', ), array( 'a', 'style="style "', - 'style="style"', + '', ), array( 'a', @@ -1275,6 +1281,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { /** * Testing the safecss_filter_attr() function. * + * @ticket 65738 * @ticket 37248 * @ticket 42729 * @ticket 48376 @@ -1290,20 +1297,44 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * * @dataProvider data_safecss_filter_attr * - * @param string $css A string of CSS rules. - * @param string $expected Expected string of CSS rules. + * @param string $css A string of CSS rules. + * @param string $expected Expected string of CSS rules. + * @param string|null $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. Default null. */ - public function test_safecss_filter_attr( $css, $expected ) { - $this->assertSame( $expected, safecss_filter_attr( $css ) ); + public function test_safecss_filter_attr( $css, $expected, $expected_legacy = null ) { + $this->assertEqualStyleAttribute( $expected, safecss_filter_attr( $css ) ); + } + + /** + * Tests the legacy implementation of safecss_filter_attr() through the `wp_kses_force_legacy_css_parser` filter. + * + * @ticket 65738 + * + * @dataProvider data_safecss_filter_attr + * + * @param string $css A string of CSS rules. + * @param string $expected Expected string of CSS rules. + * @param string|null $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. Default null. + */ + public function test_safecss_filter_attr_legacy( $css, $expected, $expected_legacy = null ) { + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $actual = safecss_filter_attr( $css ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + + $this->assertSame( $expected_legacy ?? $expected, $actual ); } /** - * Data provider for test_safecss_filter_attr(). + * Data provider for test_safecss_filter_attr() and test_safecss_filter_attr_legacy(). * * @return array { * @type array { - * @type string $css A string of CSS rules. - * @type string $expected Expected string of CSS rules. + * @type string $css A string of CSS rules. + * @type string $expected Expected string of CSS rules. + * @type string $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. * } * } */ @@ -1324,10 +1355,11 @@ public function data_safecss_filter_attr() { 'css' => 'margin-top: 2px', 'expected' => 'margin-top: 2px', ), - // Backslash \ isn't supported. + // Escapes are decoded and re-serialized. array( - 'css' => 'margin-top: \2px', - 'expected' => '', + 'css' => 'margin-top: \2px', + 'expected' => 'margin-top:\2 px;', + 'expected_legacy' => '', ), // Curly bracket } isn't supported. array( @@ -1339,10 +1371,11 @@ public function data_safecss_filter_attr() { 'css' => 'text-transform: uppercase', 'expected' => 'text-transform: uppercase', ), - // Only lowercase attribute names are supported. + // Property names match case-insensitively and serialize in lowercase. array( - 'css' => 'Text-transform: capitalize', - 'expected' => '', + 'css' => 'Text-transform: capitalize', + 'expected' => 'text-transform:capitalize;', + 'expected_legacy' => '', ), // Uppercase attribute values goes through. array( @@ -1426,9 +1459,11 @@ public function data_safecss_filter_attr() { 'css' => 'background-image: linear-gradient(red,yellow);', 'expected' => 'background-image: linear-gradient(red,yellow)', ), + // Gradients are allowed on every property. array( - 'css' => 'color: linear-gradient(red,yellow);', - 'expected' => '', + 'css' => 'color: linear-gradient(red,yellow);', + 'expected' => 'color:linear-gradient(red,yellow);', + 'expected_legacy' => '', ), array( 'css' => 'background-image: linear-gradient(red,yellow); background: prop( red,yellow); width: 100px;', @@ -1475,20 +1510,17 @@ public function data_safecss_filter_attr() { 'css' => 'background-image: linear-gradient(135deg, hsl(0,100%,50%) 0%, hsl(240,100%,50%) 100%)', 'expected' => 'background-image: linear-gradient(135deg, hsl(0,100%,50%) 0%, hsl(240,100%,50%) 100%)', ), - // Nesting beyond one level inside a gradient is not supported (unchanged from before). + // Functions nest to any depth. array( - 'css' => 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))', - 'expected' => '', + 'css' => 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))', + 'expected' => 'background-image:linear-gradient(red 0%, blue calc(50% + var(--x)));', + 'expected_legacy' => '', ), - /* - * As of 7.1 (ticket 64974) any single-level nested function is permitted inside a - * gradient, widening the previous rgb()/rgba()-only allowance. This includes the - * legacy expression() form, which is inert in all supported browsers and remains - * escaped when output as an attribute value. - */ + // A function not on the allowlist is rejected inside a gradient too. array( - 'css' => 'background-image: linear-gradient(red, expression(alert))', - 'expected' => 'background-image: linear-gradient(red, expression(alert))', + 'css' => 'background-image: linear-gradient(red, expression(alert))', + 'expected' => '', + 'expected_legacy' => 'background-image: linear-gradient(red, expression(alert))', ), // `object-position` introduced in 5.7.1. array( @@ -1539,15 +1571,16 @@ public function data_safecss_filter_attr() { 'css' => 'height: expression( body.scrollTop + 50 + "px" )', 'expected' => '', ), - // RGB color values are not allowed. + // Color functions are allowed on every property. array( - 'css' => 'color: rgb( 100, 100, 100 )', - 'expected' => '', + 'css' => 'color: rgb( 100, 100, 100 )', + 'expected' => 'color:rgb( 100, 100, 100 );', + 'expected_legacy' => '', ), - // RGBA color values are not allowed. array( - 'css' => 'color: rgb( 100, 100, 100, .4 )', - 'expected' => '', + 'css' => 'color: rgb( 100, 100, 100, .4 )', + 'expected' => 'color:rgb( 100, 100, 100, .4 );', + 'expected_legacy' => '', ), // Allow min(). array( @@ -1569,10 +1602,11 @@ public function data_safecss_filter_attr() { 'css' => 'width: clamp(100px, 50%, 100vw)', 'expected' => 'width: clamp(100px, 50%, 100vw)', ), - // Allow two functions in the same CSS. + // An unmatched `)` makes the declaration invalid CSS. array( - 'css' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', - 'expected' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', + 'css' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', + 'expected' => '', + 'expected_legacy' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', ), // Allow gradient() function. array( @@ -1624,40 +1658,42 @@ public function data_safecss_filter_attr() { 'css' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', 'expected' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', ), - // Malformed min, no closing `)`. + // A function left open at the end of the input is closed, as CSS closes it. array( - 'css' => 'width: min(3em + 10px', - 'expected' => '', + 'css' => 'width: min(3em + 10px', + 'expected' => 'width:min(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed max, no closing `)`. array( - 'css' => 'width: max(3em + 10px', - 'expected' => '', + 'css' => 'width: max(3em + 10px', + 'expected' => 'width:max(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed minmax, no closing `)`. array( - 'css' => 'width: minmax(3em + 10px', - 'expected' => '', + 'css' => 'width: minmax(3em + 10px', + 'expected' => 'width:minmax(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed calc, no closing `)`. array( - 'css' => 'width: calc(3em + 10px', - 'expected' => '', + 'css' => 'width: calc(3em + 10px', + 'expected' => 'width:calc(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed var, no closing `)`. array( - 'css' => 'width: var(--wp-var1', - 'expected' => '', + 'css' => 'width: var(--wp-var1', + 'expected' => 'width:var(--wp-var1);', + 'expected_legacy' => '', ), - // Malformed calc, mismatching brackets. + // Nested blocks left open at the end of the input are all closed. array( - 'css' => 'width: calc(3em + (10px * 2)', - 'expected' => '', + 'css' => 'width: calc(3em + (10px * 2)', + 'expected' => 'width:calc(3em + (10px * 2));', + 'expected_legacy' => '', ), - // Malformed var, mismatching brackets. array( - 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', - 'expected' => '', + 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', + 'expected' => 'background-color:var(--wp-var, var(--wp-var-fallback, pink));', + 'expected_legacy' => '', ), // Don't allow expressions outside of a calc(). array( @@ -1721,17 +1757,21 @@ public function data_safecss_filter_attr() { 'css' => 'aspect-ratio: expression( 16 / 9 );', 'expected' => '', ), + // The `;` is inside the open function, so it is part of the value. array( - 'css' => 'aspect-ratio: calc( 16 / 9;', - 'expected' => '', + 'css' => 'aspect-ratio: calc( 16 / 9;', + 'expected' => 'aspect-ratio:calc( 16 / 9;);', + 'expected_legacy' => '', ), array( 'css' => 'aspect-ratio: calc( 16 / 9 );', 'expected' => 'aspect-ratio: calc( 16 / 9 )', ), + // url() is allowed on every property. array( - 'css' => 'aspect-ratio: url( https://wordpress.org/wp-content/uploads/aspect-ratio.jpg );', - 'expected' => '', + 'css' => 'aspect-ratio: url( https://wordpress.org/wp-content/uploads/aspect-ratio.jpg );', + 'expected' => 'aspect-ratio:url(https://wordpress.org/wp-content/uploads/aspect-ratio.jpg);', + 'expected_legacy' => '', ), // URL support for `filter` introduced in 6.3. array( @@ -1747,10 +1787,11 @@ public function data_safecss_filter_attr() { 'css' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', 'expected' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', ), - // Malformed repeat, no closing `)`. + // Open at the end of the input: closed. array( - 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', - 'expected' => '', + 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', + 'expected' => 'grid-template-columns:repeat(4, minmax(0, 1fr));', + 'expected_legacy' => '', ), // Malformed repeat, contains unsupported function. array( @@ -2130,20 +2171,44 @@ public function data_wp_kses_style_attr_decodes_entities_before_css_filtering() * @ticket 45067 * @ticket 46197 * @ticket 46498 + * @ticket 65738 * - * @param string $input The style attribute saved in the editor. - * @param string $expected The sanitized style attribute. + * @param string $input The style attribute saved in the editor. + * @param string $expected The sanitized style attribute. + * @param string|null $expected_legacy Optional. The sanitized style attribute from the legacy + * implementation, when it differs. Default null. */ - public function test_kses_style_attr_with_url( $input, $expected ) { + public function test_kses_style_attr_with_url( $input, $expected, $expected_legacy = null ) { $actual = safecss_filter_attr( $input ); - $this->assertSame( $expected, $actual ); + $this->assertEqualStyleAttribute( $expected, $actual ); + } + + /** + * Tests URL sanitization in the legacy implementation of safecss_filter_attr(). + * + * @ticket 65738 + * + * @dataProvider data_kses_style_attr_with_url + * + * @param string $input The style attribute saved in the editor. + * @param string $expected The sanitized style attribute. + * @param string|null $expected_legacy Optional. The sanitized style attribute from the legacy + * implementation, when it differs. Default null. + */ + public function test_kses_style_attr_with_url_legacy( $input, $expected, $expected_legacy = null ) { + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $actual = safecss_filter_attr( $input ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + + $this->assertSame( $expected_legacy ?? $expected, $actual ); } /** * Data provider testing style attribute sanitization. * - * @return array Nested array of input, expected pairs. + * @return array Nested array of input, expected output and, when the legacy implementation + * differs, its expected output. */ public function data_kses_style_attr_with_url() { return array( @@ -2209,15 +2274,10 @@ public function data_kses_style_attr_with_url() { * Invalid use cases. */ - // Attribute doesn't support URL properties. + // url() is allowed on every property. array( 'color: url( "http://example.com/invalid.gif" );', - '', - ), - - // Mismatched quotes. - array( - 'background-image: url( "http://example.com/valid.gif\' );', + 'color:url( "http://example.com/invalid.gif" );', '', ), @@ -2262,12 +2322,6 @@ public function data_kses_style_attr_with_url() { 'background-image: url( "http://example.com" ;', '', ), - - // Malformed, no closing `"`. - array( - 'background-image: url( "http://example.com );', - '', - ), ); } @@ -2282,9 +2336,14 @@ public function data_kses_style_attr_with_url() { * @param string $expected Expected string of CSS rules. */ public function test_safecss_filter_attr_filtered( $css, $expected ) { + // The filter is applied by the legacy implementation only. + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); - $this->assertSame( $expected, safecss_filter_attr( $css ) ); + $actual = safecss_filter_attr( $css ); remove_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + + $this->assertSame( $expected, $actual ); } /** diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php new file mode 100644 index 0000000000000..7ac0f971a2760 --- /dev/null +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -0,0 +1,1039 @@ +assertSame( '', safecss_filter_attr( 'foo:bar' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color:red' ) ); + } + + /** + * @ticket 65738 + */ + public function test_property_names_match_case_insensitively() { + $this->assertSame( 'text-transform: capitalize;', safecss_filter_attr( 'Text-transform: capitalize' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'COLOR:red' ) ); + } + + /** + * @ticket 65738 + */ + public function test_allowed_list_entries_match_case_insensitively() { + $add = static function ( $allowed ) { + $allowed[] = 'Foo-Bar'; + return $allowed; + }; + add_filter( 'safe_style_css', $add ); + $actual = safecss_filter_attr( 'Foo-Bar: baz; foo-bar: baz; FOO-BAR: baz' ); + remove_filter( 'safe_style_css', $add ); + + $this->assertSame( 'foo-bar: baz; foo-bar: baz; foo-bar: baz;', $actual ); + } + + /** + * @ticket 65738 + */ + public function test_custom_property_name_must_match_grammar() { + $this->assertSame( '--a_b-1: red;', safecss_filter_attr( '--a_b-1: red' ) ); + $this->assertSame( '--miXeD-CAse: red;', safecss_filter_attr( '--miXeD-CAse: red' ) ); + // `\.` decodes to `.`, which the grammar rejects, though the declaration parses. + $this->assertSame( '', safecss_filter_attr( '--a\.b: red' ) ); + } + + /** + * @ticket 65738 + */ + public function test_custom_property_requires_wildcard_in_allowed_list() { + $without_wildcard = static function ( $allowed ) { + return array_diff( $allowed, array( '--*' ) ); + }; + add_filter( 'safe_style_css', $without_wildcard ); + $actual = safecss_filter_attr( '--x: red; color: red' ); + remove_filter( 'safe_style_css', $without_wildcard ); + + $this->assertSame( 'color: red;', $actual ); + } + + /** + * @ticket 65738 + */ + public function test_function_must_be_in_allowed_list() { + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: foo(1px)' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: calcmax(100px + 50%)' ) ); + } + + /** + * @ticket 65738 + */ + public function test_nested_function_must_be_in_allowed_list() { + $this->assertSame( 'width: calc(min(1px));', safecss_filter_attr( 'width: calc(min(1px))' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: calc(foo(1px))' ) ); + } + + /** + * @ticket 65738 + */ + public function test_disallowed_function_inside_gradient_is_rejected() { + $this->assertSame( '', safecss_filter_attr( 'background-image: linear-gradient(red, expression(alert))' ) ); + } + + /** + * @ticket 65738 + */ + public function test_function_name_matches_case_insensitively() { + $this->assertSame( 'transform: skewX(30deg);', safecss_filter_attr( 'transform: skewX(30deg)' ) ); + $this->assertSame( 'width: CALC(1px);', safecss_filter_attr( 'width: CALC(1px)' ) ); + } + + /** + * @ticket 65738 + */ + public function test_functions_nest_to_any_depth() { + $this->assertSame( + 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)));', + safecss_filter_attr( 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))' ) + ); + } + + /** + * @ticket 65738 + */ + public function test_color_functions_gradients_and_urls_are_allowed_on_every_property() { + $this->assertSame( 'color: rgb(1, 2, 3);', safecss_filter_attr( 'color: rgb(1, 2, 3)' ) ); + $this->assertSame( 'color: linear-gradient(red,yellow);', safecss_filter_attr( 'color: linear-gradient(red,yellow)' ) ); + $this->assertSame( 'aspect-ratio: url(https://example.com/a.jpg);', safecss_filter_attr( 'aspect-ratio: url(https://example.com/a.jpg)' ) ); + } + + /** + * @ticket 65738 + * + * @dataProvider data_filter_functions + * + * @param string $css CSS declaration. + */ + public function test_filter_functions_are_allowed( $css ) { + $this->assertSame( $css, safecss_filter_attr( $css ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_filter_functions() { + return array( + 'blur' => array( 'filter: blur(5px);' ), + 'brightness' => array( 'filter: brightness(0.4);' ), + 'contrast' => array( 'filter: contrast(200%);' ), + 'drop-shadow' => array( 'filter: drop-shadow(16px 16px 20px rgb(0 0 255 / 50%));' ), + 'grayscale' => array( 'filter: grayscale(50%);' ), + 'hue-rotate' => array( 'filter: hue-rotate(90deg);' ), + 'invert' => array( 'filter: invert(75%);' ), + 'opacity' => array( 'filter: opacity(25%);' ), + 'saturate' => array( 'filter: saturate(30%);' ), + 'sepia' => array( 'filter: sepia(60%);' ), + 'chained' => array( 'filter: blur(5px) brightness(0.4);' ), + ); + } + + /** + * @ticket 65738 + * + * @dataProvider data_math_functions + * + * @param string $css CSS declaration. + */ + public function test_math_functions_are_allowed( $css ) { + $this->assertSame( $css, safecss_filter_attr( $css ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_math_functions() { + return array( + 'abs' => array( 'width: calc(100% - abs(-10px));' ), + 'sign' => array( 'width: calc(10px * sign(-1));' ), + 'round' => array( 'width: round(up, 10.5px, 1px);' ), + 'mod' => array( 'width: mod(18px, 5px);' ), + 'rem' => array( 'width: rem(18px, 5px);' ), + 'pow' => array( 'width: calc(1px * pow(2, 3));' ), + 'sqrt' => array( 'width: calc(1px * sqrt(16));' ), + 'hypot' => array( 'width: hypot(3px, 4px);' ), + 'log' => array( 'width: calc(1px * log(8, 2));' ), + 'exp' => array( 'width: calc(1px * exp(1));' ), + 'sin' => array( 'transform: rotate(calc(sin(45deg) * 1turn));' ), + 'cos' => array( 'width: calc(10px * cos(0deg));' ), + 'tan' => array( 'width: calc(10px * tan(45deg));' ), + 'asin' => array( 'transform: rotate(asin(1));' ), + 'acos' => array( 'transform: rotate(acos(0));' ), + 'atan' => array( 'transform: rotate(atan(1));' ), + 'atan2' => array( 'transform: rotate(atan2(1, 1));' ), + ); + } + + /** + * @ticket 65738 + * + * @dataProvider data_easing_functions + * + * @param string $css CSS declaration. + */ + public function test_easing_functions_are_allowed( $css ) { + $add = static function ( $allowed ) { + $allowed[] = 'transition-timing-function'; + return $allowed; + }; + add_filter( 'safe_style_css', $add ); + $actual = safecss_filter_attr( $css ); + remove_filter( 'safe_style_css', $add ); + + $this->assertSame( $css, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_easing_functions() { + return array( + 'cubic-bezier' => array( 'transition-timing-function: cubic-bezier(0.1, 0.7, 1, 0.1);' ), + 'steps' => array( 'transition-timing-function: steps(4, jump-end);' ), + 'linear' => array( 'transition-timing-function: linear(0, 0.25 75%, 1);' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_url_must_be_non_empty() { + $this->assertSame( '', safecss_filter_attr( 'background: url()' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url("")' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url( " " )' ) ); + } + + /** + * @ticket 65738 + */ + public function test_url_must_pass_protocol_check() { + $this->assertSame( 'background: url(https://example.com/a.png);', safecss_filter_attr( 'background: url(https://example.com/a.png)' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url(bad://example.com/a.png)' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url("bad://example.com/a.png")' ) ); + $this->assertSame( '', safecss_filter_attr( "background: url(' \tbad://example.com/a.png ')" ) ); + } + + /** + * @ticket 65738 + */ + public function test_url_nested_in_function_must_pass_protocol_check() { + $this->assertSame( + 'background-image: linear-gradient(url(https://example.com/a.png), red);', + safecss_filter_attr( 'background-image: linear-gradient(url(https://example.com/a.png), red)' ) + ); + $this->assertSame( '', safecss_filter_attr( 'background-image: linear-gradient(url(bad://example.com/a.png), red)' ) ); + $this->assertSame( '', safecss_filter_attr( 'background-image: linear-gradient(url("bad://example.com/a.png"), red)' ) ); + $this->assertSame( '', safecss_filter_attr( 'background-image: linear-gradient(url(), red)' ) ); + } + + /** + * @ticket 65738 + */ + public function test_url_function_accepts_only_one_string_argument() { + $this->assertSame( '', safecss_filter_attr( 'background: url("a.png" "b.png")' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url(a b)' ) ); + } + + /** + * The src() function exists so var() can supply a URL, which the filter cannot check. + * + * @ticket 65738 + */ + public function test_src_function_is_rejected() { + $this->assertSame( '', safecss_filter_attr( 'background-image: src("https://example.com/a.png")' ) ); + $this->assertSame( '', safecss_filter_attr( 'background-image: src(var(--x))' ) ); + } + + /** + * @ticket 65738 + */ + public function test_bare_parenthesis_block_is_rejected_in_standard_property() { + $this->assertSame( '', safecss_filter_attr( 'width: (1px)' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: (3em + (10px * 2))' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: linear-gradient(red) (1px)' ) ); + } + + /** + * @ticket 65738 + */ + public function test_brace_block_is_rejected_in_standard_property() { + $this->assertSame( '', safecss_filter_attr( 'color: {a:b}' ) ); + $this->assertSame( '', safecss_filter_attr( 'color: {}' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: {1px}' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'width: {}; color: red' ) ); + } + + /** + * @ticket 65738 + */ + public function test_brace_block_is_kept_in_custom_property() { + $this->assertSame( '--x: {a:b};', safecss_filter_attr( '--x: {a:b}' ) ); + $this->assertSame( '--x: {};', safecss_filter_attr( '--x: {}' ) ); + } + + /** + * @ticket 65738 + */ + public function test_square_bracket_block_is_kept_in_standard_property() { + $this->assertSame( 'grid-template-columns: [a] 1fr [b];', safecss_filter_attr( 'grid-template-columns: [a] 1fr [b]' ) ); + } + + /** + * @ticket 65738 + */ + public function test_bare_parenthesis_block_is_kept_inside_function() { + $this->assertSame( 'width: calc(3em + (10px * 2));', safecss_filter_attr( 'width: calc(3em + (10px * 2))' ) ); + } + + /** + * @ticket 65738 + */ + public function test_bare_parenthesis_block_is_kept_in_custom_property() { + $this->assertSame( '--x: (1px);', safecss_filter_attr( '--x: (1px)' ) ); + } + + /** + * CSS closes a comment, string or url token that the input ends inside, + * so the declaration is kept and the token is written closed. + * + * @ticket 65738 + * @dataProvider data_input_ending_inside_a_token + * + * @param string $css Input whose last token is a comment, string or url token cut off by the end of the input. + * @param string $expected Expected output. + */ + public function test_token_cut_off_by_the_end_of_the_input_is_written_closed( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_input_ending_inside_a_token() { + return array( + 'comment in the value' => array( 'color: red /*', 'color: red;' ), + 'comment after the last declaration' => array( 'color: red; /*', 'color: red;' ), + 'comment in the second value' => array( 'color: red; width: 1px /*', 'color: red; width: 1px;' ), + 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color: red; width: 1px;' ), + 'string in the second value' => array( 'color: red; width: "abc', 'color: red; width: "abc";' ), + 'string in a standard property' => array( 'font-family: "abc', 'font-family: "abc";' ), + 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color: red; font-family: "a\\22 ";' ), + 'url token' => array( 'background: url(http://x/a.png', 'background: url(http://x/a.png);' ), + 'url token with trailing whitespace' => array( 'background: url(http://x/a.png ', 'background: url(http://x/a.png);' ), + 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color: red; background: url(http://x/a.png);' ), + 'string in a custom property' => array( '--x: "abc', '--x: "abc";' ), + 'comment in a custom property' => array( '--x: red /*', '--x: red;' ), + 'url token in a custom property' => array( '--x: url(a', '--x: url(a);' ), + 'string after a rejected value' => array( 'color: red; foo: "abc', 'color: red;' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_token_terminated_at_end_of_input_is_kept() { + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red /* ok */' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red /**/' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red; /**/' ) ); + $this->assertSame( 'font-family: "abc";', safecss_filter_attr( 'font-family: "abc"' ) ); + $this->assertSame( 'font-family: "a\\5c ";', safecss_filter_attr( 'font-family: "a\\\\"' ) ); + $this->assertSame( '--x: "abc";', safecss_filter_attr( '--x: "abc"' ) ); + $this->assertSame( 'background: url(http://x/a.png);', safecss_filter_attr( 'background: url(http://x/a.png)' ) ); + $this->assertSame( 'background: url(http://x/a.png);', safecss_filter_attr( 'background: url(http://x/a.png )' ) ); + } + + /** + * @ticket 65738 + */ + public function test_important_is_preserved() { + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red !important' ) ); + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red!important' ) ); + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red ! important ;' ) ); + $this->assertSame( 'color: red !important; width: 1px;', safecss_filter_attr( 'color: red !important; width: 1px' ) ); + } + + /** + * @ticket 65738 + */ + public function test_comments_are_dropped() { + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: /* a */ red /* b */' ) ); + $this->assertSame( 'color: red blue;', safecss_filter_attr( 'color: red /* a */blue' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( '/* a */ color /* b */ : red' ) ); + } + + /** + * A pair of tokens that would read as other tokens when written next to + * each other keeps an empty comment between them, as CSS Syntax Level 3 + * §9 requires. There is one case per marked cell of its table. + * + * @ticket 65738 + * @dataProvider data_marked_token_pairs + * + * @param string $first First token, the row of the table. + * @param string $second Second token, the column of the table. + */ + public function test_marked_token_pair_keeps_an_empty_comment( $first, $second ) { + // A bad url makes CSS drop the declaration. + $expected = 'url(a b)' === $second ? '' : "--x: {$first}/**/{$second};"; + + $this->assertSame( $expected, safecss_filter_attr( "--x: {$first}/**/{$second}" ) ); + } + + /** + * @return array + */ + public function data_marked_token_pairs() { + $columns = array( + 'ident' => 'b', + 'function' => 'calc(1)', + 'url' => 'url(u)', + 'bad url' => 'url(a b)', + '-' => '-', + 'number' => '1', + 'percentage' => '1%', + 'dimension' => '1px', + 'CDC' => '-->', + '(' => '(1)', + '*' => '*', + '%' => '%', + ); + + $marked_after_names = array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ); + $rows = array( + 'ident' => array( 'a', array_merge( $marked_after_names, array( '(' ) ) ), + 'at-keyword' => array( '@a', $marked_after_names ), + 'hash' => array( '#a', $marked_after_names ), + 'dimension' => array( '1px', $marked_after_names ), + '#' => array( '#', $marked_after_names ), + '-' => array( '-', $marked_after_names ), + 'number' => array( '1', array( 'ident', 'function', 'url', 'bad url', 'number', 'percentage', 'dimension', 'CDC', '%' ) ), + '@' => array( '@', array( 'ident', 'function', 'url', 'bad url', '-', 'CDC' ) ), + '.' => array( '.', array( 'number', 'percentage', 'dimension' ) ), + '+' => array( '+', array( 'number', 'percentage', 'dimension' ) ), + '/' => array( '/', array( '*' ) ), + ); + + $pairs = array(); + foreach ( $rows as $row => list( $first, $marked ) ) { + foreach ( $marked as $column ) { + $pairs[ "{$row} then {$column}" ] = array( $first, $columns[ $column ] ); + } + } + + return $pairs; + } + + /** + * @ticket 65738 + * @dataProvider data_unmarked_token_pairs + * + * @param string $css Input with a comment between two tokens. + * @param string $expected Filtered output. + */ + public function test_unmarked_token_pair_drops_the_comment( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_unmarked_token_pairs() { + return array( + 'ident then comma' => array( '--x: a/**/,b', '--x: a,b;' ), + 'number then -' => array( '--x: 1/**/-', '--x: 1-;' ), + 'hash then (' => array( '--x: #a/**/(1)', '--x: #a(1);' ), + 'at-keyword then (' => array( '--x: @a/**/(1)', '--x: @a(1);' ), + '. then ident' => array( '--x: ./**/a', '--x: .a;' ), + '+ then ident' => array( '--x: +/**/a', '--x: +a;' ), + '/ then ident' => array( '--x: //**/a', '--x: /a;' ), + 'dimension then *' => array( '--x: 1px/**/*', '--x: 1px*;' ), + 'string then ident' => array( '--x: "a"/**/b', '--x: "a"b;' ), + ') then function' => array( 'width: calc(1px)/**/calc(2px)', 'width: calc(1px)calc(2px);' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_whitespace_runs_become_one_space() { + $this->assertSame( 'color: red blue;', safecss_filter_attr( "color: red \n\t blue " ) ); + $this->assertSame( 'aspect-ratio: calc( 16 / 9 );', safecss_filter_attr( 'aspect-ratio: calc( 16 / 9 );' ) ); + } + + /** + * @ticket 65738 + */ + public function test_escapes_are_decoded_before_checks() { + // Property name. + $this->assertSame( 'color: red;', safecss_filter_attr( '\63 olor: red' ) ); + // Allowed function name. + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: c\61 lc(1px)' ) ); + // Disallowed function name. + $this->assertSame( '', safecss_filter_attr( 'width: f\6f o(1px)' ) ); + // URL scheme. + $this->assertSame( '', safecss_filter_attr( 'background: url("j\61 vascript:alert")' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url(j\61 vascript:alert)' ) ); + } + + /** + * @ticket 65738 + */ + public function test_escapes_are_re_escaped_in_output() { + $this->assertSame( 'margin-top: \2 px;', safecss_filter_attr( 'margin-top: \2px' ) ); + // An escape of a letter decodes and re-serializes without the escape. + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: r\65 d' ) ); + $this->assertSame( 'font-family: "a\3c b";', safecss_filter_attr( 'font-family: "aassertSame( 'font-family: "a\22 b";', safecss_filter_attr( "font-family: 'a\"b'" ) ); + } + + /** + * CSS closes a block left open at the end of the input, so the + * declaration is checked like any other and written with the block closed. + * + * @ticket 65738 + * @dataProvider data_blocks_left_open_at_end_of_input + * + * @param string $css Input ending inside one or more blocks. + * @param string $expected Filtered output. + */ + public function test_block_left_open_at_end_of_input_is_closed( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_blocks_left_open_at_end_of_input() { + return array( + 'function' => array( 'width: calc(1px', 'width: calc(1px);' ), + 'nested functions' => array( 'width: var(--a, var(--b', 'width: var(--a, var(--b));' ), + 'parenthesis inside a function' => array( 'width: calc(3em + (10px * 2)', 'width: calc(3em + (10px * 2));' ), + 'square bracket' => array( 'width: [1px', 'width: [1px];' ), + 'url function with a string' => array( 'background: url("a.png"', 'background: url("a.png");' ), + 'url function with whitespace' => array( 'background: url( "a.png" ', 'background: url( "a.png");' ), + // The `;` is inside the open function, so there is one declaration, as a browser reads it. + 'semicolon inside the function' => array( 'width: calc(1px; color: red', 'width: calc(1px; color: red);' ), + 'custom property function' => array( '--x: calc(1px', '--x: calc(1px);' ), + 'custom property parenthesis' => array( '--x: (1px', '--x: (1px);' ), + 'custom property square bracket' => array( '--x: [a', '--x: [a];' ), + 'custom property brace' => array( '--x: {a:b', '--x: {a:b};' ), + 'after an accepted declaration' => array( 'color: red; width: calc(1px', 'color: red; width: calc(1px);' ), + 'function not on the allowlist' => array( 'width: foo(1px', '' ), + 'url function with a bad scheme' => array( 'background: url("javascript:a"', '' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_closed_block_at_end_of_input_is_kept() { + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); + $this->assertSame( '--x: (1px);', safecss_filter_attr( '--x: (1px)' ) ); + $this->assertSame( '--x: {a:b};', safecss_filter_attr( '--x: {a:b}' ) ); + } + + /** + * @ticket 65738 + */ + public function test_declaration_css_drops_is_dropped() { + // Unmatched `)`. + $this->assertSame( '', safecss_filter_attr( 'width: clamp(min(100px, 350px), 50%, 500px), 600px)' ) ); + $this->assertSame( '', safecss_filter_attr( 'margin-bottom: 2px)' ) ); + // Item with no colon. + $this->assertSame( 'width: 1px;', safecss_filter_attr( 'color; width: 1px' ) ); + // Newline inside a string. + $this->assertSame( 'width: 1px;', safecss_filter_attr( "font-family: \"a\nb; width: 1px" ) ); + } + + /** + * @ticket 65738 + */ + public function test_output_format() { + $this->assertSame( 'margin-top: 2px;', safecss_filter_attr( 'margin-top: 2px' ) ); + $this->assertSame( 'font-family: "a";', safecss_filter_attr( "font-family: 'a'" ) ); + $this->assertSame( 'background: url(a.png);', safecss_filter_attr( 'background: url(a.png)' ) ); + $this->assertSame( 'background: url( "a.png" );', safecss_filter_attr( "background: url( 'a.png' )" ) ); + $this->assertSame( 'font-weight: bold; font-size: 15px;', safecss_filter_attr( 'font-weight: bold;font-size: 15px' ) ); + } + + /** + * @ticket 65738 + */ + public function test_null_bytes_are_not_emitted() { + $actual = safecss_filter_attr( "color: a\0b" ); + $this->assertStringNotContainsString( "\0", $actual ); + $this->assertSame( "color: a\u{FFFD}b;", $actual ); + } + + /** + * @ticket 65738 + */ + public function test_empty_allowed_list_returns_the_input() { + $input = 'color: red /* keep me */; width: calc(1px; foo: bar'; + + add_filter( 'safe_style_css', '__return_empty_array' ); + $actual = safecss_filter_attr( $input ); + $legacy = safecss_filter_attr( "color:\0 red;\nwidth: 1px" ); + remove_filter( 'safe_style_css', '__return_empty_array' ); + + $this->assertSame( $input, $actual ); + $this->assertSame( 'color: red;width: 1px', $legacy ); + } + + /** + * @ticket 65738 + * @dataProvider data_non_array_allowed_list + * + * @param mixed $allowed_attr Value the `safe_style_css` filter returns. + */ + public function test_non_array_allowed_list_uses_the_default_list( $allowed_attr ) { + $callback = static function () use ( $allowed_attr ) { + return $allowed_attr; + }; + + add_filter( 'safe_style_css', $callback ); + $actual = safecss_filter_attr( 'color: red; user-select: none' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $legacy = safecss_filter_attr( 'color: red; user-select: none' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + remove_filter( 'safe_style_css', $callback ); + + $this->assertSame( 'color: red;', $actual, 'The parser-based filter should use the default list.' ); + $this->assertSame( 'color: red', $legacy, 'The legacy filter should use the default list.' ); + } + + /** + * @return array + */ + public function data_non_array_allowed_list() { + return array( + 'null' => array( null ), + 'false' => array( false ), + 'empty string' => array( '' ), + 'zero' => array( 0 ), + 'string' => array( 'color' ), + 'true' => array( true ), + 'ArrayObject' => array( new ArrayObject( array( 'color' ) ) ), + ); + } + + /** + * @ticket 65738 + * @dataProvider data_allowed_list_with_entries_that_are_not_strings + * + * @param array $allowed_attr Allowed list the `safe_style_css` filter returns. + * @param string $expected Expected output of the parser-based filter. + * @param string $expected_legacy Expected output of the legacy filter. + */ + public function test_allowed_list_entries_that_are_not_strings_are_ignored( $allowed_attr, $expected, $expected_legacy ) { + $callback = static function () use ( $allowed_attr ) { + return $allowed_attr; + }; + + add_filter( 'safe_style_css', $callback ); + $actual = safecss_filter_attr( 'color: red; display: none' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $legacy = safecss_filter_attr( 'color: red; display: none' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + remove_filter( 'safe_style_css', $callback ); + + $this->assertSame( $expected, $actual, 'Parser-based output does not match.' ); + $this->assertSame( $expected_legacy, $legacy, 'Legacy output does not match.' ); + } + + /** + * @return array + */ + public function data_allowed_list_with_entries_that_are_not_strings() { + return array( + 'array entry' => array( array( 'color', array( 'display' ) ), 'color: red;', 'color: red' ), + 'null entry' => array( array( 'color', null ), 'color: red;', 'color: red' ), + 'integer entry' => array( array( 'color', 5 ), 'color: red;', 'color: red' ), + 'only false' => array( array( false ), '', '' ), + 'only null' => array( array( null ), '', '' ), + 'only an array' => array( array( array( 'color', 'display' ) ), '', '' ), + 'only a wildcard list' => array( array( array( '--*' ) ), '', '' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_empty_allowed_list_returns_the_input_on_the_legacy_path() { + add_filter( 'safe_style_css', '__return_empty_array' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $actual = safecss_filter_attr( 'color: red /* keep me */; width: calc(1px' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + remove_filter( 'safe_style_css', '__return_empty_array' ); + + $this->assertSame( 'color: red /* keep me */; width: calc(1px', $actual ); + } + + /** + * @ticket 65738 + */ + public function test_force_legacy_css_parser_filter_selects_legacy_output() { + $seen = array(); + $legacy = static function ( $force_legacy_css_parser ) use ( &$seen ) { + $seen[] = $force_legacy_css_parser; + return true; + }; + add_filter( 'wp_kses_force_legacy_css_parser', $legacy ); + $actual = safecss_filter_attr( 'margin-top: 2px; color: rgb(1,2,3)' ); + remove_filter( 'wp_kses_force_legacy_css_parser', $legacy ); + + $this->assertSame( 'margin-top: 2px', $actual ); + $this->assertSame( array( false ), $seen ); + } + + /** + * @ticket 65738 + * @expectedDeprecated safecss_filter_attr_allow_css + */ + public function test_allow_css_filter_is_deprecated_and_not_applied() { + $calls = new MockAction(); + add_filter( 'safecss_filter_attr_allow_css', array( $calls, 'filter' ) ); + $actual = safecss_filter_attr( 'margin-top: 2px' ); + remove_filter( 'safecss_filter_attr_allow_css', array( $calls, 'filter' ) ); + + $this->assertSame( 0, $calls->get_call_count() ); + $this->assertSame( 'margin-top: 2px;', $actual ); + } + + /** + * @ticket 65738 + */ + public function test_allow_css_filter_does_not_change_the_output() { + $this->setExpectedDeprecated( 'safecss_filter_attr_allow_css' ); + + $inputs = array( 'margin-top: 2px', 'width: foo(1px)', 'color: red; width: calc(1px' ); + $without = array_map( 'safecss_filter_attr', $inputs ); + + add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + $allow = array_map( 'safecss_filter_attr', $inputs ); + remove_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + + add_filter( 'safecss_filter_attr_allow_css', '__return_false' ); + $deny = array_map( 'safecss_filter_attr', $inputs ); + remove_filter( 'safecss_filter_attr_allow_css', '__return_false' ); + + $this->assertSame( $without, $allow ); + $this->assertSame( $without, $deny ); + } + + /** + * @ticket 65738 + */ + public function test_allow_css_filter_is_not_reported_when_no_callback_is_attached() { + $this->assertSame( 'margin-top: 2px;', safecss_filter_attr( 'margin-top: 2px' ) ); + } + + /** + * @ticket 65738 + * @dataProvider data_ampersand_delimiters + * + * @param string $css Input with a `&` delimiter. + */ + public function test_ampersand_delimiter_is_rejected( $css ) { + $this->assertSame( '', safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_ampersand_delimiters() { + return array( + 'top level' => array( 'color: red & blue' ), + 'in calc' => array( 'width: calc(1px & 2px)' ), + 'in custom' => array( '--x: a & b' ), + 'in var' => array( 'color: var(--x, a&b)' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_ampersand_delimiter_rejects_only_its_own_declaration() { + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red; width: 1px &' ) ); + $this->assertSame( 'width: 1px;', safecss_filter_attr( 'color: a & b; width: 1px' ) ); + } + + /** + * @ticket 65738 + * @dataProvider data_control_character_delimiters + * + * @param string $css Input with a control character delimiter. + */ + public function test_control_character_delimiter_is_rejected( $css ) { + $this->assertSame( 'width: 1px;', safecss_filter_attr( "$css; width: 1px" ) ); + } + + /** + * @return array + */ + public function data_control_character_delimiters() { + return array( + 'U+0001' => array( "color: red\x01" ), + 'U+0008' => array( "color: red\x08" ), + 'U+000B' => array( "color: red\x0B" ), + 'U+000E' => array( "color: red\x0E" ), + 'U+001F' => array( "color: red\x1F" ), + 'U+007F' => array( "color: red\x7F" ), + 'in calc' => array( "width: calc(1px \x01 2px)" ), + 'in custom' => array( "--x: a\x01b" ), + 'in var' => array( "color: var(--x, a\x01)" ), + ); + } + + /** + * wp_sanitize_html_kses() removes raw control characters from the + * document and decodes character references afterward, so `` reaches + * the filter as a control character. + * + * @ticket 65738 + */ + public function test_control_character_from_a_character_reference_is_rejected() { + $this->assertSame( '

x

', wp_kses_post( '

x

' ) ); + } + + /** + * @ticket 65738 + * @dataProvider data_backslash_delimiters + * + * @param string $css Input with a `\` delimiter. + */ + public function test_backslash_delimiter_is_rejected( $css ) { + $this->assertSame( 'width: 1px;', safecss_filter_attr( "$css; width: 1px" ) ); + } + + /** + * @return array + */ + public function data_backslash_delimiters() { + return array( + 'before a line feed' => array( "color: red \\\n" ), + 'before a carriage return' => array( "color: red \\\r\n" ), + 'before a form feed' => array( "color: red \\\f" ), + 'in calc' => array( "width: calc(1px \\\n+ 2px)" ), + 'in custom' => array( "--x: a \\\n b" ), + ); + } + + /** + * @ticket 65738 + * @dataProvider data_output_without_ampersand + * + * @param string $css Input with a `&` in a token other than a delimiter. + */ + public function test_output_contains_no_ampersand( $css ) { + $output = safecss_filter_attr( $css ); + + $this->assertNotSame( '', $output ); + $this->assertStringNotContainsString( '&', $output ); + } + + /** + * @return array + */ + public function data_output_without_ampersand() { + return array( + 'string' => array( 'font-family: "a&b"' ), + 'quoted url' => array( 'background: url("a?x=1&y=2")' ), + 'unquoted url' => array( 'background: url(a?x=1&y=2)' ), + 'escaped ident' => array( 'font-family: a\26 b' ), + 'escaped hash' => array( 'color: #a\26 b' ), + 'custom string' => array( '--x: "&"' ), + ); + } + + /** + * @ticket 65738 + * @dataProvider data_delimiters_kept + * + * @param string $css Input with a `<`, `>` or `=` delimiter or a `` token. + * @param string $expected Filtered output. + */ + public function test_other_delimiters_are_kept( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_delimiters_kept() { + return array( + 'less than' => array( '--x: a < b', '--x: a < b;' ), + 'greater than' => array( '--x: a > b', '--x: a > b;' ), + 'equals' => array( '--x: a = b', '--x: a = b;' ), + 'CDO' => array( '--x: ', '--x: a -->;' ), + 'in calc' => array( 'width: calc(1px < 2px)', 'width: calc(1px < 2px);' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_delimiters_are_escaped_inside_strings_and_urls() { + $this->assertSame( 'font-family: "a\26 b\3c c\3e d=e";', safecss_filter_attr( 'font-family: "a&bd=e"' ) ); + $this->assertSame( 'background: url(a?x=1\26 y=2);', safecss_filter_attr( 'background: url(a?x=1&y=2)' ) ); + $this->assertSame( 'background: url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url("a?x=1&y=2")' ) ); + $this->assertSame( '--x: "\\3c !--";', safecss_filter_attr( '--x: "