Skip to content

CSS API: Add CSS token processor foundations - #92

Draft
sirreal wants to merge 40 commits into
trunkfrom
css-api/css-token-processor-foundations
Draft

sirreal wants to merge 40 commits into
trunkfrom
css-api/css-token-processor-foundations

Conversation

@sirreal

@sirreal sirreal commented Jul 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add WP_CSS_Token_Processor as the low-level CSS Syntax tokenization foundation
  • add WP_CSS_Builder, used to serialize token values safely
  • support safe, repeatable updates to URL and string token values
  • load both classes during WordPress bootstrap
  • add focused unit coverage and the synchronized PHP Toolkit CSS corpus

This is intentionally extracted from css-api/add-css-token-processor without its adjacent stylesheet parser, font normalization, selector, and custom test-harness explorations. It provides a small base for follow-up CSS API work, including the style-attribute processor in #78.

Validation

  • WP_TESTS_SKIP_INSTALL=1 vendor/bin/phpunit tests/phpunit/tests/css-api/wpCssBuilder.php — 46 tests, 200 assertions
  • WP_TESTS_SKIP_INSTALL=1 vendor/bin/phpunit tests/phpunit/tests/css-api/wpCssTokenProcessor.php — 266 tests, 385 assertions
  • vendor/bin/phpcs --standard=phpcs.xml.dist on the four PHP files
  • git diff --check

Trac ticket: https://core.trac.wordpress.org/ticket/62653

Use of AI Tools

AI assistance: Yes
Tool(s): ChatGPT/Codex
Model(s): GPT-5
Used for: extracting the CSS foundations from the broader API exploration, refining the implementation and tests, adversarial review, and updating this PR description.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@sirreal
sirreal force-pushed the css-api/css-token-processor-foundations branch from b7e1df7 to 2a146f7 Compare July 23, 2026 10:31
@sirreal
sirreal force-pushed the css-api/css-token-processor-foundations branch from 2a146f7 to 7c1178c Compare August 26, 2026 09:57
Comment on lines +120 to +156
public static function string( string $value ): string {
$value = wp_scrub_utf8( $value );
$escaped = strtr(
$value,
array(
// Escape existing backslashes to prevent unintentional escapes in result.
'\\' => '\\5C ',

// Pre-processing replaces NULLs and some newlines. Replace and escape as necessary.
"\0" => "\u{FFFD}",

// Normalize and replace newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing
"\r\n" => '\\A ',
"\r" => '\\A ',
"\f" => '\\A ',

// Newlines must be escaped in CSS strings.
"\n" => '\\A ',

// Arbitrary characters for Unicode escaping:

// HTML syntax may be problematic.
'<' => '\\3C ',
'>' => '\\3E ',
'&' => '\\26 ',

// CSS syntax may be problematic.
',' => '\\2C ',
';' => '\\3B ',
'{' => '\\7B ',
'}' => '\\7D ',
'"' => '\\22 ',
"'" => '\\27 ',
)
);
return "\"{$escaped}\"";
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This form of escaping is problematic because there are places in Core that use sanitize_text_field() for CSS. In the space-terminated escape sequences, the terminating whitespace character is significant, so the first space is consumed by the escape sequence. This means that 1 space becomes 0, 2 becomes 1, etc. If the adjacent 2 spaces are collapsed to 1, it breaks that input.

https://github.com/WordPress/wordpress-develop/blob/36d425e7bbd177507f0df6d543b1cb8d8a5da41a/src/wp-includes/fonts/class-wp-font-utils.php#L65

https://github.com/WordPress/wordpress-develop/blob/36d425e7bbd177507f0df6d543b1cb8d8a5da41a/src/wp-includes/formatting.php#L5751-L5754

That function seems unsuitable for the CSS escaping and should probably be replaced with the CSS functions in this PR, but in the interest of being defensive it may be best to always use the 6 character form of the escapes: \00003C instead of \3C . This may be less confusing and less likely to break downstream expectations, although either is a valid CSS Unicode escape sequence.

@sirreal
sirreal force-pushed the css-api/css-token-processor-foundations branch from 2ed7203 to 7711c31 Compare September 25, 2026 07:07
@sirreal
sirreal force-pushed the css-api/css-token-processor-foundations branch from 7711c31 to a9d4b5d Compare October 9, 2026 08:13
sirreal added 15 commits October 9, 2026 20:21
CSS preprocessing replaces NUL with U+FFFD before tokenization, so an unquoted url() containing NUL is a url-token, not a bad-url-token. The source byte stays in the token for offsets; the value decodes it.

Ported from WordPress/php-toolkit bb1c7069 with its CSSPreprocessingTest cases.
Ident sequences and unquoted url values advance over runs of bytes that need no special handling with one strspn() call instead of one PHP loop iteration per byte. Tokenizing 2,000 copies of a three-declaration style string (url, dimension, custom property) drops from 54 ms to 14 ms.

Ported from WordPress/php-toolkit 8e411e7c.
decode_range() and its escape helpers take the CSS source as an argument, so code that holds the source and a token's offsets can decode the token after the cursor has moved on.
get_token() returns the current token as an object that keeps its values after the processor moves on: type, flag, value, unit, start, length, and whether it ended at its closing delimiter. The value is decoded from the source on the first call to get_value(). The processor stays a cursor; get_token_value() reads the same object, so the value decoding is in one place.
The token processor reads CSS; the new class writes it. serialize_string() and serialize_ident() move unchanged, with their tests.
WP_CSS_Token_Serializer::serialize() writes tokens read by the processor as CSS that reads as the same tokens. Comments are dropped, and each whitespace run, including one broken by comments, becomes one space. An empty comment, /**/, separates each token pair the §9 table marks when no whitespace is between them. Tokens are escaped again from their decoded values: a `\` delim keeps its newline, a hash is escaped by its type flag, and a unit that reads as an exponent has its `e` escaped. Strings and urls cut off by the end of the input are written closed, and blocks still open at the end are closed in reverse order.

Bad-string and bad-url tokens have no value; they are written as a quote and a newline, and as `url(()`.

serialize_string() still normalizes newlines in its argument. Token values are quoted without that step, so an escaped CR or FF in a string keeps its code point.
CSS Syntax 3 §9 allows a tokenizer to preserve comments when they have no effect on parsing. The processor emits them so callers can find comments by byte offset.
serialize_string() replaced CR, CRLF and FF in its argument with LF, as if the argument were CSS source. Its output is escaped and never preprocessed, so the replacement changed the value: a string token written from "a\D b" read back with a newline. CR and FF are now written as \D and \C, so the string reads back as the same value; NULL still becomes U+FFFD. A caller that wants normalized line endings normalizes before calling.

serialize() writes string tokens with serialize_string() again, and the private quote_string() is removed.
The CSS Syntax 3 §9 table compares token types in pairs. It misses two sequences that the tokenizer reads as a CDO or CDC token: `<`, `!` and a token that starts with `--` spell `<!--`, and an ident `--` followed by `>` spells `-->`. So `<!/**/--x` was written as `<!--x` (CDO, ident `x`) and `--/**/>` as `-->`. An empty comment is now written before the last token of each, so the output reads as the same tokens.
A token list can hold tokens that have no valid CSS form, so the public way to write CSS will be at the declaration level, where the style attribute processor exposes only valid declarations. WP_CSS_Token_Serializer is marked @access private; its methods stay public so other core classes can call them.

serialize_ident() and serialize_string() had no caller outside the serializer. They become escape_ident(), public for property names, and the private escape_string(); url values use the private escape_url(), which now returns the whole url token. CSSOM defines "serialize an identifier" and "serialize a string" with a different escape set, so the new names avoid suggesting those algorithms.

The string escaping tests now run through serialize(). The class documentation states what the output guarantees: it parses to the same CSS as the tokens. It is not always valid CSS: bad-string and bad-url tokens are written as the same bad tokens.
escape_ident() repeated escape_name()'s loop with three changes for the first code point. It now handles a lone `-` and a leading digit, or `-` and a digit, and escapes the rest with escape_name(). Output is unchanged: 80,565 generated values of up to five code points escape the same as before.
Hash flag round-trips get a data provider, with the expected flag of each input checked on both sides. serialize() is tested with a generator, since it accepts any iterable, and with a DEL delim.
String tokens wrote U+0001 to U+001F and U+007F raw, except LF, CR and FF. They now escape every code point in that range, as CSSOM's "serialize a string" does, so a raw tab is written `\9 `. Identifiers, hash names and urls already escaped these code points.
CSSOM's "escape a character as code point" writes the hex digits in lowercase, so `<` is written `\3c ` instead of `\3C `. CSS reads either case, so the output parses as before.
CSS Syntax limits the non-ASCII code points an identifier may contain to those HTML allows in custom element names. The tokenizer, like Chrome, still reads every code point above U+007F as an identifier code point, and escape_name() wrote them all raw: `a\a0 b` was written with a raw no-break space, which a tokenizer that follows the spec reads as an ident, a delim and an ident. escape_name() now hex-escapes the code points outside the set, so identifiers, hash names and dimension units read the same under both rules.
The CSS API is not in 7.2.0. The `_doing_it_wrong()` call in consume_string() also names 7.3.0 instead of 1.0.0.
- `@link` tags hold URLs and `@see` tags name code. Related constants name each other: string and bad-string, url and bad-url, CDC and CDO, the hash token and its type flags, and the serializer's follower lists and `MARKED_PAIRS`.
- Docblock examples are `php interactive` blocks that print their result. The class example that gathered bad strings had no string in its input and found none; it now has one.
- Box drawing shows the token and value spans of the offset properties.
- The UTF-8 note links the HTML chapter 3 of Unicode 18.0.0, section 3.9.6, U+FFFD Substitution of Maximal Subparts, instead of the Unicode 9.0.0 PDF.
- Four CSS Syntax anchors did not exist: `typedef-CDC-token` and `typedef-CDO-token` are lowercase, `delim-token-diagram` is `typedef-delim-token`, and token values are defined under `tokenization`.
- Comment closers are written as `*` `/`, CSS and HTML literals are in backticks, and comments use the impersonal voice.
The tokenizer stored value offsets for comment tokens that nothing read and that were wrong: the value started at the `/` of the opener, and its length was the token length minus 4 even when the input ended before the closer, so `/* a` had an empty value. The value now starts after `/*` and ends before `*/` or at the end of the input.

WP_CSS_Token::get_value() and get_token_value() return that text for a comment token instead of null. Comments contain no escapes, so `/*\41*/` has the value `\41`; only input preprocessing applies, as for other values: CR, CRLF and FF become LF, and NUL and invalid UTF-8 become U+FFFD.

The six comment tokens in the corpus and the normalization test now expect the text, and the corpus README describes the rule.
The docblock said a `\` followed by the end of the input is not a valid escape, but the function returns true there, as CSS Syntax requires: only a newline rejects it. The spec step that rejects a newline was written above the end-of-input branch, which returns true, and the newline check was labeled with the step that returns true. Each comment now precedes the code it describes.
get_normalized_token() decoded escapes in every token, so `/* \41 */` was normalized to `/* A*/`. CSS comments contain no escapes. The new WP_CSS_Token_Processor::preprocess_range() applies input preprocessing only: CR, CRLF and FF become LF, and NUL and invalid UTF-8 become U+FFFD. get_normalized_token() uses it for comment tokens, and WP_CSS_Token::get_value() uses it for comment values instead of its own copy of the same rules.

Corpus case `tests/comment/0004` expected the decoding: `/*\*/` normalized to `/**/`. It now expects `/*\*/`, and the corpus README says escapes are decoded outside comments only.
The cursor keeps next_token(), get_token(), get_token_type(),
get_token_start() and get_token_length(): it says what the current token
is and where it is. WP_CSS_Token says what the token contains.

Removed: get_token_value(), get_token_type_flag(), get_token_unit() and
is_token_terminated(), each a copy of a WP_CSS_Token getter;
get_unnormalized_token(), a substr() of the source by the offsets the
cursor already exposes; and get_normalized_token(), the decoded text of
the whole token span, which no code in src reads.

The `normalized` column of the css-test-cases.json fixture existed only
to check get_normalized_token(), so it is removed with it. The
collect_tokens() test helper now takes the CSS source, since it needs
the source to slice each token's raw text.
Code that puts a PHP value into CSS has no public way to escape it:
the serializer's escapers are internal, so each caller writes its own.
WP_CSS_Builder writes a value as a quoted string, a url() function
holding a quoted string, or an identifier, each reading back as the
given value.

The escape rules stay in WP_CSS_Token_Serializer, which may later
escape for other contexts or with other character sets; the builder
forwards to it. escape_string() becomes public and marked
@access private, as escape_ident() already is.

url() writes the quoted form, url("..."), so it needs only the string
escapes. The unquoted url token needs its own escapes for parentheses,
quotes and whitespace.
The developer documentation site skips declarations marked @ignore.
Every PHP-private member and every declaration marked @access private
now has it, as the HTML API does. Private members without a @SInCE
tag gain @SInCE 7.3.0 with it.
The per-type description of a token's value and the termination rules
were on WP_CSS_Token_Processor::get_token_value() and
is_token_terminated(), which no longer exist. They move to
WP_CSS_Token::get_value() and is_terminated(), which had one-line
descriptions. The class example that echoed get_normalized_token() now
echoes token types, and the @see tags naming removed methods are gone.
The parameter accepted only 'UTF-8' and returned null for any other
value, so create() was typed static|null and every caller checked for a
null it could not get. Every caller passes text that is already decoded:
an attribute value or a string from the database.

An optional trailing parameter can be added in a later release without
breaking callers if the tokenizer ever reads other encodings; removing
one after release cannot.
Replace the static create() factory and private constructor with a
public __construct( string $css ).

A factory is useful when creation can fail, has several modes, or picks
the returned class. Since the encoding argument was removed, create()
does none of these. WP_HTML_Tag_Processor and WP_Block_Processor also
take their input through a public constructor.

This also removes the PHPStan new.static error: a subclass constructor
with another signature would make new static( $css ) throw a TypeError.
utf8_ord() returns an int, and _wp_scan_utf8() has already confirmed
that the bytes are one valid code point.
next_token() advances the cursor, so two calls on one processor can
return different results. Without the annotation PHPStan reuses the
first call's result for the second, which reports a later
"if ( $processor->next_token() )" as always true and its else branch
as unreachable.

WP_HTML_Tag_Processor::next_token() carries the same annotation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant