Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8dc9446
Add css token processor
sirreal Mar 27, 2026
cc0ddbf
phpcbf
sirreal Mar 27, 2026
f7d3940
Fix missing methods
sirreal Mar 27, 2026
c514409
Add test group
sirreal Mar 27, 2026
8d5cb76
rename test file
sirreal Mar 27, 2026
8f1edc8
Fix css test path
sirreal Mar 27, 2026
cb470bf
Include css token processor
sirreal Mar 27, 2026
552c2d5
Add create_css_string
sirreal Mar 27, 2026
9940a5b
Update test
sirreal Mar 27, 2026
2415bf8
Sync string escapes with GB PR
sirreal Mar 27, 2026
906d826
Merge branch 'trunk' into css-api/add-css-token-processor
sirreal Mar 30, 2026
c07634a
Fix example phpdoc spacing
sirreal Mar 30, 2026
b7a302f
Adjust some documentation
sirreal Mar 30, 2026
ab67840
Try improving token types
sirreal Mar 30, 2026
2e8767e
Add CSS builder class
sirreal Mar 30, 2026
272a747
Font: add and use normalize_css_font_face_font_family method
sirreal Mar 30, 2026
0abb128
Add css builder tests
sirreal Mar 30, 2026
8d7b59b
Harmonize with https://github.com/WordPress/gutenberg/pull/76782 tests
sirreal Mar 30, 2026
65f91e1
Use printed Unicode replacement character
sirreal Mar 30, 2026
da70697
Add CSS string token round-trip tests
sirreal Mar 30, 2026
8ee9122
try webdriver/qunit tests
sirreal Mar 31, 2026
9b70c59
Fix switch continue
sirreal Mar 31, 2026
f26fc68
Revert "try webdriver/qunit tests"
sirreal Mar 31, 2026
d3f231d
Merge branch 'trunk' into css-api/add-css-token-processor
sirreal Mar 31, 2026
2e58814
Fix variable name typo
sirreal Mar 31, 2026
dd9c108
Update tests for quoted @font-face font-family
sirreal Mar 31, 2026
cc6bf38
Add method test suite
sirreal Mar 31, 2026
b920d7d
More nice test cases
sirreal Mar 31, 2026
c83aa4e
Cleanup function
sirreal Mar 31, 2026
c2da39f
On normalization failure, stringify entire input as CSS
sirreal Apr 1, 2026
2c3c2f9
Trim CSS whitespace
sirreal Apr 1, 2026
da0311f
Update existing test for behavioral change
sirreal Apr 1, 2026
4f0cf3b
Add more test cases
sirreal Apr 1, 2026
52fb57c
Use css string builder in maybe_add_quotes
sirreal Apr 1, 2026
9d0be2e
DROPME: Add dev files
sirreal Apr 1, 2026
22d7a39
Scaffold CSS processor
sirreal Apr 1, 2026
c8a0ee2
DROPME: update dev files
sirreal Apr 1, 2026
10b4ead
ident + font family style normaliztion i1
sirreal Apr 1, 2026
ff0e180
Add font normalization tests
sirreal Apr 6, 2026
417d7a0
parse_a_rule TDD scaffold
sirreal Apr 6, 2026
6e21363
Implement parse_a_rule
sirreal Apr 6, 2026
5a7b36b
Implement parse_a_rule()
sirreal Apr 9, 2026
ac0ad63
Implement normalization
sirreal Apr 9, 2026
9cc1030
Add parse_a_list_of_declarations()
sirreal Apr 9, 2026
2829bb1
implemenet declaration list processing
sirreal Apr 9, 2026
c56e56d
Add wp_scrub_utf8() to inputs of string and ident methods
Copilot Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/wp-admin/includes/class-wp-posts-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ public function column_title( $post ) {
if ( get_option( 'wp_collaboration_enabled' ) ) {
$locked_avatar = '';
/* translators: Collaboration status message for a singular post in the post list. Can be any type of post. */
$locked_text = esc_html_x( 'Currently being edited', 'post list' );
$locked_text = esc_html_x( 'Currently being edited', 'post list' );
} else {
$lock_holder = get_userdata( $lock_holder );
$locked_avatar = get_avatar( $lock_holder->ID, 18 );
Expand Down
241 changes: 241 additions & 0 deletions src/wp-includes/css-api/class-wp-css-builder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<?php

abstract class WP_CSS_Builder {
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.

@copilot Add wp_scrub_utf8() to the inputs of string and ident methods.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Added wp_scrub_utf8() to both ident() and string() method inputs in c56e56d.

/**
* Create a CSS ident token from a plain PHP string value.
*
* Characters not valid in CSS identifiers are hex-escaped. This uses
* the same safety escaping as {@see WP_CSS_Builder::string()} for HTML
* and CSS-sensitive characters, plus escaping of whitespace and other
* characters not permitted in idents.
*
* @see https://www.w3.org/TR/css-syntax-3/#escaping
* @see https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier
*
* @param string $value Decoded string value to encode as a CSS ident.
* @return string CSS ident token text.
*/
public static function ident( string $value ): string {
$value = wp_scrub_utf8( $value );
$result = '';
$length = strlen( $value );

for ( $i = 0; $i < $length; $i++ ) {
$byte = ord( $value[ $i ] );

// NULL → U+FFFD REPLACEMENT CHARACTER.
if ( 0x00 === $byte ) {
$result .= "\u{FFFD}";
continue;
}

// Non-ASCII bytes (≥ 0x80): valid in idents, pass through.
if ( $byte >= 0x80 ) {
$result .= $value[ $i ];
continue;
}

// ASCII letters and underscore: always valid in idents.
if (
( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z
( $byte >= 0x61 && $byte <= 0x7A ) || // a-z
0x5F === $byte // _
) {
$result .= $value[ $i ];
continue;
}

// Hyphen: valid in idents, but check for hyphen-digit at start.
if ( 0x2D === $byte ) {
// Hyphen at position 0 followed by a digit at position 1: escape the digit.
if ( 0 === $i && $i + 1 < $length && ord( $value[ $i + 1 ] ) >= 0x30 && ord( $value[ $i + 1 ] ) <= 0x39 ) {
$result .= '-';
++$i;
$result .= sprintf( '\\%X ', ord( $value[ $i ] ) );
continue;
}
$result .= '-';
continue;
}

// Digits: valid except at position 0.
if ( $byte >= 0x30 && $byte <= 0x39 ) {
if ( 0 === $i ) {
$result .= sprintf( '\\%X ', $byte );
} else {
$result .= $value[ $i ];
}
continue;
}

// Everything else: hex-escape.
$result .= sprintf( '\\%X ', $byte );
}

return $result;
}

/**
* Create a quoted CSS string from a plain PHP string value.
*
* Example:
* $value = 'CSS & a "<style>" tag\'s strings';
* $css_string = WP_CSS_Builder::string( $value );
* echo "<style>*::before { content: {$css_string}; }</style>";
*
* CSS strings are quoted many characters that are problematic in HTML
* or may be complicated for rudimentary CSS or HTML processors to handle
* are encoded using Unicode escape sequences.
*
* @see https://www.w3.org/TR/css-syntax-3/#escaping
*/
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}\"";
}

public static function normalize_and_escape_css( string $css ): string {
$css = wp_scrub_utf8( $css );
$processor = WP_CSS_Token_Processor::create( $css );
if ( null === $processor ) {
return '';
}

$normalized_css = '';

while ( $processor->next_token() ) {
switch ( $processor->get_token_type() ) {

// Basic punctuation:
case WP_CSS_Token_Processor::TOKEN_SEMICOLON: $normalized_css .= ';'; break;
case WP_CSS_Token_Processor::TOKEN_COMMA: $normalized_css .= ','; break;
case WP_CSS_Token_Processor::TOKEN_WHITESPACE: $normalized_css .= ' '; break;
case WP_CSS_Token_Processor::TOKEN_COLON: $normalized_css .= ':'; break;

// Paired punctuation:
case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: $normalized_css .= '{'; break;
case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: $normalized_css .= '}'; break;
case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: $normalized_css .= '('; break;
case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: $normalized_css .= ')'; break;
case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: $normalized_css .= '['; break;
case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: $normalized_css .= ']'; break;

// "@" + ident
case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD:
$normalized_css .= '@' . self::ident( $processor->get_token_value() );
break;

// ident + "("
case WP_CSS_Token_Processor::TOKEN_FUNCTION:
$normalized_css .= self::ident( $processor->get_token_value() ) . '(';
break;

/*
* Hash tokens are not idents but their value can be escaped as such.
*
* ‖→ "#" →─┐ ┌──────────────────────────────┐ ┌─→‖
* ├─→─┤ a-z A-Z 0-9 _ - or non-ASCII ├─→─┤
* │ └──────────────────────────────┘ │
* │ ┌──────────────────────────────┐ │
* ├─→─┤ escape ├─→─┤
* │ └──────────────────────────────┘ │
* └──────────────────←───────────────────┘
*/
case WP_CSS_Token_Processor::TOKEN_HASH:
$normalized_css .= '#' . self::ident( $processor->get_token_value() );
break;

case WP_CSS_Token_Processor::TOKEN_DIMENSION:
$normalized_css .= $processor->get_token_value() . $processor->get_token_unit();
break;

case WP_CSS_Token_Processor::TOKEN_PERCENTAGE:
$normalized_css .= "%{$processor->get_token_value()}";
break;

case WP_CSS_Token_Processor::TOKEN_NUMBER:
$normalized_css .= $processor->get_token_value();
break;

case WP_CSS_Token_Processor::TOKEN_DELIM:
$normalized_css .= $processor->get_token_value();
break;

case WP_CSS_Token_Processor::TOKEN_IDENT:
$normalized_css .= self::ident( $processor->get_token_value() );
break;

case WP_CSS_Token_Processor::TOKEN_STRING:
var_dump( $processor->get_token_value() );
$normalized_css .= self::string( $processor->get_token_value() );
break;

// Keep or strip comments?
case WP_CSS_Token_Processor::TOKEN_COMMENT:
$normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() );
break;

/**
* A <bad-string-token> is an open string that reaches a newline.
*
* @see https://www.w3.org/TR/css-syntax-3/#consume-string-token
*
* @see https://www.w3.org/TR/css-syntax-3/#preserved-tokens
* > Note: The tokens <}-token>s, <)-token>s, <]-token>, <bad-string-token>, and <bad-url-token> are always parse errors, but they are preserved in the token stream by this specification to allow other specs, such as Media Queries, to define more fine-grained error-handling than just dropping an entire declaration or block.
*/
case WP_CSS_Token_Processor::TOKEN_BAD_STRING:
$normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() ) . "\n";
break;

case WP_CSS_Token_Processor::TOKEN_URL:
case WP_CSS_Token_Processor::TOKEN_BAD_URL:
case WP_CSS_Token_Processor::TOKEN_CDC:
case WP_CSS_Token_Processor::TOKEN_CDO:
default:
throw new Error( 'unhandled token type ' . $processor->get_token_type() . ' with value ' . var_export( $processor->get_token_value(), true ) );
}
}

return strtr(
$normalized_css,
array(
' ' => '␠',
"\t" => "␉\t",
"\n" => "␊\n",
)
);
}
}
Loading