Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
883 / 883
100.00% covered (success)
100.00%
65 / 65
CRAP
100.00% covered (success)
100.00%
1 / 1
MatcherFactory
100.00% covered (success)
100.00%
883 / 883
100.00% covered (success)
100.00%
65 / 65
106
100.00% covered (success)
100.00%
1 / 1
 singleton
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 optionalWhitespace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 significantWhitespace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 comma
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ident
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 customIdent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 string
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 urlstring
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cssWideKeywords
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 calcSum
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 makeFuncNameChecker
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 mathFunction
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
1
 rawInteger
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 colorHex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 integer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rawNumber
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 number
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ratio
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 rawPercentage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 percentage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lengthPercentage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 frequencyPercentage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 anglePercentage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 timePercentage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 numberPercentage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 dimension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 zero
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rawLength
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 length
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rawAngle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 angle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rawTime
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 time
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rawFrequency
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 frequency
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rawResolution
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 resolution
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 colorFuncs
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
2
 safeColor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 color
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 image
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
2
 position
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 bgPosition
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 cssMediaQuery
100.00% covered (success)
100.00%
100 / 100
100.00% covered (success)
100.00%
1 / 1
5
 cssMediaQueryList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cssSupportsCondition
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 cssDeclaration
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 cssSingleEasingFunction
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 counterStyle
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 cssSelectorList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 cssSelector
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 cssCombinator
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 cssSimpleSelectorSeq
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 cssTypeSelector
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssNamespacePrefix
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 cssOptionalNamespacePrefix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cssUniversal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssID
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 cssClass
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssAttrib
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 cssPseudo
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
2
 cssANplusB
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
7
 cssNegation
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 colorWords
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @file
4 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
5 */
6
7namespace Wikimedia\CSS\Grammar;
8
9use Closure;
10use Wikimedia\CSS\Objects\ComponentValueList;
11use Wikimedia\CSS\Objects\Token;
12use Wikimedia\CSS\Parser\Parser;
13use Wikimedia\CSS\Sanitizer\PropertySanitizer;
14
15/**
16 * Factory for predefined Grammar matchers
17 * @note For security, the attr() and var() functions are not supported,
18 * although as a limited exception var() is allowed for color attributes
19 * in `::colorFuncs()`.
20 */
21class MatcherFactory {
22    /** @var MatcherFactory|null */
23    private static $instance = null;
24
25    /** @var (Matcher|Matcher[])[] Cache of constructed matchers */
26    protected $cache = [];
27
28    /** @var string[] length units */
29    protected static $lengthUnits = [
30        // Font-relative units
31        'em', 'rem', 'ex', 'rex',
32        'cap', 'rcap', 'ch', 'rch',
33        'ic', 'ric', 'lh', 'rlh',
34        // Viewport-relative units
35        'vw', 'svw', 'lvw', 'dvw',
36        'vh', 'svh', 'lvh', 'dvh',
37        'vi', 'svi', 'lvi', 'dvi',
38        'vb', 'svb', 'lvb', 'dvb',
39        'vmin', 'svmin', 'lvmin', 'dvmin',
40        'vmax', 'svmax', 'lvmax', 'dvmax',
41        // Absolute units
42        'cm', 'mm', 'Q', 'in', 'pc', 'pt', 'px',
43    ];
44
45    /** @var string[] angle units */
46    protected static $angleUnits = [ 'deg', 'grad', 'rad', 'turn' ];
47
48    /** @var string[] time units */
49    protected static $timeUnits = [ 's', 'ms' ];
50
51    /** @var string[] frequency units */
52    protected static $frequencyUnits = [ 'Hz', 'kHz' ];
53
54    /** @var string[] resolution units */
55    protected static $resolutionUnits = [ 'dpi', 'dpcm', 'dppx', 'x' ];
56
57    /**
58     * Return a static instance of the factory
59     * @return MatcherFactory
60     */
61    public static function singleton() {
62        if ( !self::$instance ) {
63            self::$instance = new self();
64        }
65        return self::$instance;
66    }
67
68    /**
69     * Matcher for optional whitespace
70     * @return Matcher
71     */
72    public function optionalWhitespace() {
73        return $this->cache[__METHOD__]
74            ??= new WhitespaceMatcher( [ 'significant' => false ] );
75    }
76
77    /**
78     * Matcher for required whitespace
79     * @return Matcher
80     */
81    public function significantWhitespace() {
82        return $this->cache[__METHOD__]
83            ??= new WhitespaceMatcher( [ 'significant' => true ] );
84    }
85
86    /**
87     * Matcher for a comma
88     * @return Matcher
89     */
90    public function comma() {
91        return $this->cache[__METHOD__]
92            ??= new TokenMatcher( Token::T_COMMA );
93    }
94
95    /**
96     * Matcher for an arbitrary identifier
97     * @return Matcher
98     */
99    public function ident() {
100        return $this->cache[__METHOD__]
101            ??= new TokenMatcher( Token::T_IDENT );
102    }
103
104    /**
105     * Matcher for a <custom-ident>
106     *
107     * Note this doesn't implement the semantic restriction about assigning
108     * meaning to various idents in a complex value, as CSS Sanitizer doesn't
109     * deal with semantics on that level.
110     *
111     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#custom-idents
112     * @param string[] $exclude Additional values to exclude, all-lowercase.
113     * @return Matcher
114     */
115    public function customIdent( array $exclude = [] ) {
116        $exclude = array_merge( [
117            // https://www.w3.org/TR/2024/WD-css-values-4-20240312/#common-keywords
118            'initial', 'inherit', 'unset', 'default',
119            // https://www.w3.org/TR/2022/CR-css-cascade-4-20220113/#all-shorthand
120            'revert'
121        ], $exclude );
122        return new TokenMatcher( Token::T_IDENT, static function ( Token $t ) use ( $exclude ) {
123            return !in_array( strtolower( $t->value() ), $exclude, true );
124        } );
125    }
126
127    /**
128     * Matcher for a string
129     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#strings
130     * @warning If the string will be used as a URL, use self::urlstring() instead.
131     * @return Matcher
132     */
133    public function string() {
134        return $this->cache[__METHOD__]
135            ??= new TokenMatcher( Token::T_STRING );
136    }
137
138    /**
139     * Matcher for a string containing a URL
140     * @param string $type Type of resource referenced, e.g. "image" or "audio".
141     *  Not used here, but might be used by a subclass to validate the URL more strictly.
142     * @return Matcher
143     */
144    public function urlstring( $type ) {
145        return $this->string();
146    }
147
148    /**
149     * Matcher for a URL
150     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#urls
151     * @param string $type Type of resource referenced, e.g. "image" or "audio".
152     *  Not used here, but might be used by a subclass to validate the URL more strictly.
153     * @return Matcher
154     */
155    public function url( $type ) {
156        return $this->cache[__METHOD__]
157            ??= new UrlMatcher();
158    }
159
160    /**
161     * CSS-wide value keywords
162     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#common-keywords
163     * @return Matcher
164     */
165    public function cssWideKeywords() {
166        return $this->cache[__METHOD__]
167            ??= new KeywordMatcher( [
168                // https://www.w3.org/TR/2024/WD-css-values-4-20240312/#common-keywords
169                'initial', 'inherit', 'unset',
170                // added by https://www.w3.org/TR/2022/CR-css-cascade-4-20220113/#all-shorthand
171                'revert'
172            ] );
173    }
174
175    /**
176     * Matcher for a calculation <calc-sum>
177     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#calc-syntax
178     * @return Matcher
179     */
180    protected function calcSum() {
181        if ( !isset( $this->cache[__METHOD__] ) ) {
182            $ows = $this->optionalWhitespace();
183            $ws = $this->significantWhitespace();
184
185            // Definitions are recursive. This will be used by reference and later
186            // will be replaced.
187            $calcValue = new NothingMatcher();
188
189            $calcProduct = new Juxtaposition( [
190                &$calcValue,
191                Quantifier::star(
192                    new Juxtaposition( [ $ows, new DelimMatcher( [ '*', '/' ] ), $ows, &$calcValue ] )
193                ),
194            ] );
195
196            $calcSum = new Juxtaposition( [
197                $ows,
198                $calcProduct,
199                Quantifier::star( new Juxtaposition( [
200                    $ws, new DelimMatcher( [ '+', '-' ] ), $ws, $calcProduct
201                ] ) ),
202                $ows,
203            ] );
204            // Save it to the cache before it is fully resolved, so that we can call
205            // number() etc. This allows things like calc( sin( 1 ) ) since sin() is
206            // a math function on the same level as calc() itself.
207            $this->cache[__METHOD__] = $calcSum;
208
209            $calcKeyword = new KeywordMatcher( [ 'e', 'pi', 'infinity', '-infinity', 'NaN' ] );
210            // calc() forces values to be numeric so it is safe to allow custom props here
211            $customProp = new FunctionMatcher( 'var', new Juxtaposition( [
212                new CustomPropertyMatcher(),
213                Quantifier::optional( $calcSum ),
214            ], true ) );
215
216            // Complete the recursive rule <calc-value>
217            $calcValue = new Alternative( [
218                $this->number(),
219                $this->dimension(),
220                $this->percentage(),
221                $calcKeyword,
222                $customProp,
223                new BlockMatcher( Token::T_LEFT_PAREN, $calcSum )
224            ] );
225        }
226        return $this->cache[__METHOD__];
227    }
228
229    /**
230     * Create a function which returns true if the name passed to it is a
231     * function with the specified number or type of arguments.
232     *
233     * @param int|string $argType
234     * @return Closure
235     */
236    private function makeFuncNameChecker( $argType ) {
237        $funcArgs = [
238            'calc' => 1,
239            'min' => '#',
240            'max' => '#',
241            'clamp' => 'clamp',
242            'round' => 'round',
243            'mod' => 2,
244            'rem' => 2,
245            'sin' => 1,
246            'cos' => 1,
247            'tan' => 1,
248            'asin' => 1,
249            'acos' => 1,
250            'atan' => 1,
251            'atan2' => 2,
252            'pow' => 2,
253            'sqrt' => 1,
254            'hypot' => '#',
255            'log' => 'log',
256            'exp' => 1,
257            'abs' => 1,
258            'sign' => 1,
259        ];
260        return static function ( $name ) use ( $funcArgs, $argType ) {
261            // phpcs:ignore Generic.ControlStructures.DisallowYodaConditions
262            return $funcArgs[ strtolower( $name ) ] ?? null === $argType;
263        };
264    }
265
266    /**
267     * Match either a math function such as calc() or a specified value type.
268     *
269     * Note: CSS Values Level 4 is much more permissive than Level 3. Checking
270     * of types such as length is deferred until runtime, and non-integer return
271     * values in an integer context are rounded instead of being rejected at
272     * parse time.
273     *
274     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#calc-syntax
275     * @return Matcher
276     */
277    protected function mathFunction( Matcher $typeMatcher ) {
278        $calcSum = $this->calcSum();
279
280        $matchers = [ $typeMatcher ];
281
282        // Functions with one argument
283        $matchers[] = new FunctionMatcher(
284            $this->makeFuncNameChecker( 1 ),
285            $calcSum
286        );
287
288        // Functions with two arguments
289        $matchers[] = new FunctionMatcher(
290            $this->makeFuncNameChecker( 2 ),
291            new Juxtaposition( [ $calcSum, $calcSum ], true )
292        );
293
294        // Functions with N arguments separated by commas
295        $matchers[] = new FunctionMatcher(
296            $this->makeFuncNameChecker( '#' ),
297            Quantifier::hash( $calcSum )
298        );
299
300        // clamp
301        $clampArg = new Alternative( [
302            $calcSum,
303            new KeywordMatcher( 'none' )
304        ] );
305        $matchers[] = new FunctionMatcher(
306            $this->makeFuncNameChecker( 'clamp' ),
307            new Juxtaposition( [ $clampArg, $calcSum, $clampArg ], true )
308        );
309
310        // round
311        $roundingStrategy = new KeywordMatcher( [ 'nearest', 'up', 'down', 'to-zero' ] );
312        $matchers[] = new FunctionMatcher(
313            $this->makeFuncNameChecker( 'round' ),
314            new Juxtaposition(
315                [
316                    Quantifier::optional( $roundingStrategy ),
317                    $calcSum,
318                    Quantifier::optional( $calcSum )
319                ],
320                true
321            )
322        );
323
324        // log
325        $matchers[] = new FunctionMatcher(
326            $this->makeFuncNameChecker( 'log' ),
327            new Juxtaposition(
328                [
329                    $calcSum,
330                    Quantifier::optional( $calcSum )
331                ],
332                true
333            )
334        );
335        return new Alternative( $matchers );
336    }
337
338    /**
339     * Matcher for an integer value, without math functions
340     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#integers
341     * @return Matcher
342     */
343    protected function rawInteger() {
344        return $this->cache[__METHOD__]
345            ??= new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
346                // The spec says it must match /^[+-]\d+$/, but the tokenizer
347                // should have marked any other number token as a 'number'
348                // anyway so let's not bother checking.
349                return $t->typeFlag() === 'integer';
350            } );
351    }
352
353    /**
354     * @return TokenMatcher
355     */
356    public function colorHex(): TokenMatcher {
357        return $this->cache[__METHOD__]
358            ??= new TokenMatcher( Token::T_HASH, static function ( Token $t ) {
359                return preg_match( '/^([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $t->value() );
360            } );
361    }
362
363    /**
364     * Matcher for an integer value
365     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#integers
366     * @return Matcher
367     */
368    public function integer() {
369        return $this->cache[__METHOD__]
370            ??= $this->mathFunction( $this->rawInteger() );
371    }
372
373    /**
374     * Matcher for a real number, without math functions
375     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#numbers
376     * @return Matcher
377     */
378    public function rawNumber() {
379        return $this->cache[__METHOD__]
380            ??= new TokenMatcher( Token::T_NUMBER );
381    }
382
383    /**
384     * Matcher for a real number
385     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#numbers
386     * @return Matcher
387     */
388    public function number() {
389        return $this->cache[__METHOD__]
390            ??= $this->mathFunction( $this->rawNumber() );
391    }
392
393    /**
394     * Ratio values
395     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ratios
396     * @return Matcher
397     */
398    public function ratio() {
399        return $this->cache[__METHOD__]
400            // <ratio> = <number [0,∞]> [ / <number [0,∞]> ]?
401            ??= new Alternative( [
402                $this->rawNumber(),
403                new Juxtaposition( [
404                    $this->rawNumber(),
405                    $this->optionalWhitespace(),
406                    new DelimMatcher( [ '/' ] ),
407                    $this->optionalWhitespace(),
408                    $this->rawNumber(),
409                ] ),
410            ] );
411    }
412
413    /**
414     * Matcher for a percentage value, without math functions
415     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#percentages
416     * @return Matcher
417     */
418    public function rawPercentage() {
419        return $this->cache[__METHOD__]
420            ??= new TokenMatcher( Token::T_PERCENTAGE );
421    }
422
423    /**
424     * Matcher for a percentage value
425     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#percentages
426     * @return Matcher
427     */
428    public function percentage() {
429        return $this->cache[__METHOD__]
430            ??= $this->mathFunction( $this->rawPercentage() );
431    }
432
433    /**
434     * Matcher for a length-percentage value
435     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#typedef-length-percentage
436     * @return Matcher
437     */
438    public function lengthPercentage() {
439        return $this->cache[__METHOD__]
440            ??= $this->mathFunction(
441                new Alternative( [ $this->rawLength(), $this->rawPercentage() ] )
442            );
443    }
444
445    /**
446     * Matcher for a frequency-percentage value
447     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#typedef-frequency-percentage
448     * @return Matcher
449     */
450    public function frequencyPercentage() {
451        return $this->cache[__METHOD__]
452            ??= $this->mathFunction(
453                new Alternative( [ $this->rawFrequency(), $this->rawPercentage() ] )
454            );
455    }
456
457    /**
458     * Matcher for an angle-percentage value
459     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#typedef-angle-percentage
460     * @return Matcher
461     */
462    public function anglePercentage() {
463        return $this->cache[__METHOD__]
464            ??= $this->mathFunction(
465                new Alternative( [ $this->rawAngle(), $this->rawPercentage() ] )
466            );
467    }
468
469    /**
470     * Matcher for a time-percentage value
471     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#typedef-time-percentage
472     * @return Matcher
473     */
474    public function timePercentage() {
475        return $this->cache[__METHOD__]
476            ??= $this->mathFunction(
477                new Alternative( [ $this->rawTime(), $this->rawPercentage() ] )
478            );
479    }
480
481    /**
482     * A convenience method for matching <number>|<percentage>.
483     *
484     * In CSS Values 3 there was a <number-percentage> production, but this was
485     * removed in CSS Values 4 with the note "<number> and <percentage> can't
486     * be combined in calc()". Things that previously used <number-percentage>
487     * were updated to use <number>|<percentage>. So, following Values 4, we
488     * will return a matcher for <number>|<percentage> here.
489     *
490     * Note that calc(1 + 50%) is still allowed at parse time since <calc-value>
491     * can now be either <number> or <percentage>.
492     *
493     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#percentages
494     * @return Matcher
495     */
496    public function numberPercentage() {
497        return $this->cache[__METHOD__]
498            ??= new Alternative( [ $this->number(), $this->percentage() ] );
499    }
500
501    /**
502     * Matcher for a dimension value
503     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#dimensions
504     * @return Matcher
505     */
506    public function dimension() {
507        return $this->cache[__METHOD__]
508            ??= new TokenMatcher( Token::T_DIMENSION );
509    }
510
511    /**
512     * Matches the number 0
513     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#zero-value
514     * @return Matcher
515     */
516    public function zero() {
517        return $this->cache[__METHOD__]
518            ??= new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
519                return $t->value() === 0 || $t->value() === 0.0;
520            } );
521    }
522
523    /**
524     * Matcher for a length value, without math functions
525     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#lengths
526     * @return Matcher
527     */
528    protected function rawLength() {
529        if ( !isset( $this->cache[__METHOD__] ) ) {
530            $unitsRe = '/^(' . implode( '|', self::$lengthUnits ) . ')$/i';
531
532            $this->cache[__METHOD__] = new Alternative( [
533                $this->zero(),
534                new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) use ( $unitsRe ) {
535                    return preg_match( $unitsRe, $t->unit() );
536                } ),
537            ] );
538        }
539        return $this->cache[__METHOD__];
540    }
541
542    /**
543     * Matcher for a length value
544     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#lengths
545     * @return Matcher
546     */
547    public function length() {
548        return $this->cache[__METHOD__]
549            ??= $this->mathFunction( $this->rawLength() );
550    }
551
552    /**
553     * Matcher for an angle value, without math functions
554     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#angles
555     * @return Matcher
556     */
557    protected function rawAngle() {
558        if ( !isset( $this->cache[__METHOD__] ) ) {
559            $unitsRe = '/^(' . implode( '|', self::$angleUnits ) . ')$/i';
560
561            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
562                static function ( Token $t ) use ( $unitsRe ) {
563                    return preg_match( $unitsRe, $t->unit() );
564                }
565            );
566        }
567        return $this->cache[__METHOD__];
568    }
569
570    /**
571     * Matcher for an angle value
572     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#angles
573     * @return Matcher
574     */
575    public function angle() {
576        return $this->cache[__METHOD__]
577            ??= $this->mathFunction( $this->rawAngle() );
578    }
579
580    /**
581     * Matcher for a duration (time) value, without math functions
582     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#time
583     * @return Matcher
584     */
585    protected function rawTime() {
586        if ( !isset( $this->cache[__METHOD__] ) ) {
587            $unitsRe = '/^(' . implode( '|', self::$timeUnits ) . ')$/i';
588
589            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
590                static function ( Token $t ) use ( $unitsRe ) {
591                    return preg_match( $unitsRe, $t->unit() );
592                }
593            );
594        }
595        return $this->cache[__METHOD__];
596    }
597
598    /**
599     * Matcher for a duration (time) value
600     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#time
601     * @return Matcher
602     */
603    public function time() {
604        return $this->cache[__METHOD__]
605            ??= $this->mathFunction( $this->rawTime() );
606    }
607
608    /**
609     * Matcher for a frequency value, without math functions
610     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#frequency
611     * @return Matcher
612     */
613    protected function rawFrequency() {
614        if ( !isset( $this->cache[__METHOD__] ) ) {
615            $unitsRe = '/^(' . implode( '|', self::$frequencyUnits ) . ')$/i';
616
617            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
618                static function ( Token $t ) use ( $unitsRe ) {
619                    return preg_match( $unitsRe, $t->unit() );
620                }
621            );
622        }
623        return $this->cache[__METHOD__];
624    }
625
626    /**
627     * Matcher for a frequency value
628     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#frequency
629     * @return Matcher
630     */
631    public function frequency() {
632        return $this->cache[__METHOD__]
633            ??= $this->mathFunction( $this->rawFrequency() );
634    }
635
636    /**
637     * Matcher for a raw resolution value, without math functions
638     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#resolution
639     * @return Matcher
640     */
641    protected function rawResolution() {
642        if ( !isset( $this->cache[__METHOD__] ) ) {
643            $unitsRe = '/^(' . implode( '|', self::$resolutionUnits ) . ')$/i';
644            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
645                static function ( Token $t ) use ( $unitsRe ) {
646                    return preg_match( $unitsRe, $t->unit() );
647                }
648            );
649        }
650        return $this->cache[__METHOD__];
651    }
652
653    /**
654     * Matcher for a resolution value
655     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#resolution
656     * @return Matcher
657     */
658    public function resolution() {
659        return $this->cache[__METHOD__]
660            ??= $this->mathFunction( $this->rawResolution() );
661    }
662
663    /**
664     * Matchers for color functions
665     * @return Matcher[]
666     */
667    protected function colorFuncs() {
668        if ( !isset( $this->cache[__METHOD__] ) ) {
669            $n = $this->number();
670            $p = $this->percentage();
671            $hue = new Alternative( [ $this->angle(), $n ] );
672
673            $none = new KeywordMatcher( 'none' );
674            $nPNone = new Alternative( [ $this->numberPercentage(), $none ] );
675            $hueNone = new Alternative( [ $hue, $none ] );
676
677            $colorSpaceParams = new Alternative( [
678                new Juxtaposition( [
679                    new KeywordMatcher( [
680                        'srgb', 'srgb-linear', 'display-p3', 'a98-rgb',
681                        'prophoto-rgb', 'rec2020'
682                    ] ),
683                    Quantifier::count( $nPNone, 3, 3 ),
684                ] ),
685                new Juxtaposition( [
686                    new KeywordMatcher( [ 'xyz', 'xyz-d50', 'xyz-d65' ] ),
687                    Quantifier::count( $nPNone, 3, 3 ),
688                ] ),
689            ] );
690
691            $optionalAlpha = Quantifier::optional( new Juxtaposition(
692                [ new DelimMatcher( '/' ), new Alternative( [ $nPNone, $none ] ) ]
693            ) );
694            $optionalLegacyAlpha = Quantifier::optional( $nPNone );
695
696            $rgb = new Alternative( [
697                new Juxtaposition( [ Quantifier::hash( $p, 3, 3 ), $optionalLegacyAlpha ], true ),
698                new Juxtaposition( [ Quantifier::hash( $n, 3, 3 ), $optionalLegacyAlpha ], true ),
699                new Juxtaposition( [ $nPNone, $nPNone, $nPNone, $optionalAlpha ] ),
700            ] );
701            $hsl = new Alternative( [
702                new Juxtaposition( [ $hue, $p, $p, $optionalLegacyAlpha ], true ),
703                new Juxtaposition( [ $hueNone, $nPNone, $nPNone, $optionalAlpha ] ),
704            ] );
705            $hwb = new Juxtaposition( [ $hueNone, $nPNone, $nPNone, $optionalAlpha ] );
706            $lab = new Juxtaposition( [ $nPNone, $nPNone, $nPNone, $optionalAlpha ] );
707            $lch = new Juxtaposition( [ $nPNone, $nPNone, $hueNone, $optionalAlpha ] );
708            $color = new Juxtaposition( [ $colorSpaceParams, $optionalAlpha ] );
709
710            $this->cache[__METHOD__] = [
711                new FunctionMatcher( 'rgb', $rgb ),
712                new FunctionMatcher( 'rgba', $rgb ),
713                new FunctionMatcher( 'hsl', $hsl ),
714                new FunctionMatcher( 'hsla', $hsl ),
715                new FunctionMatcher( 'hwb', $hwb ),
716                new FunctionMatcher( 'lab', $lab ),
717                new FunctionMatcher( 'lch', $lch ),
718                new FunctionMatcher( 'oklab', $lab ),
719                new FunctionMatcher( 'oklch', $lch ),
720                new FunctionMatcher( 'color', $color )
721            ];
722        }
723        return $this->cache[__METHOD__];
724    }
725
726    /**
727     * Matcher for a color value, *not* including a custom property reference.
728     *
729     * Because custom properties can lead to unexpected behavior (generally
730     * a bad thing for security) when concatenated together, this matcher
731     * should be used for CSS rules which allow value concatenation.
732     * For example, `border-color` allows up to 4 `var(...)` expressions to
733     * potentially be concatenated.
734     *
735     * @see https://www.w3.org/TR/2022/CR-css-variables-1-20220616/#custom-property
736     * @return Matcher
737     */
738    public function safeColor() {
739        return $this->cache[__METHOD__]
740            ??= new Alternative( array_merge( [
741                $this->colorWords(),
742                $this->colorHex(),
743            ], $this->colorFuncs() ) );
744    }
745
746    /**
747     * Matcher for a color value, including a possible custom property
748     * reference and light-dark color function.
749     *
750     * Follows:
751     * * https://www.w3.org/TR/2025/CRD-css-color-4-20250424/
752     * * https://www.w3.org/TR/css-variables-1/
753     * * https://www.w3.org/TR/2024/WD-css-color-5-20240229/#funcdef-light-dark
754     *
755     * @return Matcher
756     */
757    public function color() {
758        return $this->cache[__METHOD__]
759            ??= new Alternative( [
760                $this->safeColor(),
761                new FunctionMatcher( 'var', new Juxtaposition( [
762                    new CustomPropertyMatcher(),
763                    Quantifier::optional( $this->safeColor() ),
764                ], true ) ),
765                new FunctionMatcher( 'light-dark', new Juxtaposition( [
766                    $this->safeColor(),
767                    $this->safeColor(),
768                ], true ) ),
769            ] );
770    }
771
772    /**
773     * Matcher for an image value
774     * @see https://www.w3.org/TR/2023/CRD-css-images-3-20231218/#image-values
775     * @return Matcher
776     */
777    public function image() {
778        if ( !isset( $this->cache[__METHOD__] ) ) {
779            // https://www.w3.org/TR/2023/CRD-css-images-3-20231218/#gradients
780            $c = $this->comma();
781            $colorStop = new Juxtaposition( [
782                $this->color(),
783                Quantifier::optional( $this->lengthPercentage() ),
784            ] );
785            $colorStopList = new Juxtaposition( [
786                $colorStop,
787                Quantifier::hash( new Juxtaposition( [
788                    Quantifier::optional( $this->lengthPercentage() ),
789                    $colorStop
790                ], true ) ),
791            ], true );
792            $atPosition = new Juxtaposition( [ new KeywordMatcher( 'at' ), $this->position() ] );
793
794            $linearGradient = new Juxtaposition( [
795                Quantifier::optional( new Juxtaposition( [
796                    new Alternative( [
797                        new Alternative( [
798                            $this->zero(),
799                            $this->angle(),
800                        ] ),
801                        new Juxtaposition( [ new KeywordMatcher( 'to' ), UnorderedGroup::someOf( [
802                            new KeywordMatcher( [ 'left', 'right' ] ),
803                            new KeywordMatcher( [ 'top', 'bottom' ] ),
804                        ] ) ] )
805                    ] ),
806                    $c
807                ] ) ),
808                $colorStopList,
809            ] );
810            $radialGradient = new Juxtaposition( [
811                Quantifier::optional( new Juxtaposition( [
812                    new Alternative( [
813                        new Juxtaposition( [
814                            new Alternative( [
815                                UnorderedGroup::someOf( [ new KeywordMatcher( 'circle' ), $this->length() ] ),
816                                UnorderedGroup::someOf( [
817                                    new KeywordMatcher( 'ellipse' ),
818                                    Quantifier::count( $this->lengthPercentage(), 2, 2 )
819                                ] ),
820                                UnorderedGroup::someOf( [
821                                    new KeywordMatcher( [ 'circle', 'ellipse' ] ),
822                                    new KeywordMatcher( [
823                                        'closest-corner', 'closest-side', 'farthest-corner', 'farthest-side',
824                                    ] ),
825                                ] ),
826                            ] ),
827                            Quantifier::optional( $atPosition ),
828                        ] ),
829                        $atPosition
830                    ] ),
831                    $c
832                ] ) ),
833                $colorStopList,
834            ] );
835
836            // Putting it all together
837            $this->cache[__METHOD__] = new Alternative( [
838                $this->url( 'image' ),
839                new FunctionMatcher( 'linear-gradient', $linearGradient ),
840                new FunctionMatcher( 'radial-gradient', $radialGradient ),
841                new FunctionMatcher( 'repeating-linear-gradient', $linearGradient ),
842                new FunctionMatcher( 'repeating-radial-gradient', $radialGradient ),
843            ] );
844        }
845        return $this->cache[__METHOD__];
846    }
847
848    /**
849     * Matcher for a position value
850     * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#typedef-position
851     * @return Matcher
852     */
853    public function position() {
854        if ( !isset( $this->cache[__METHOD__] ) ) {
855            $lp = $this->lengthPercentage();
856
857            $this->cache[__METHOD__] = new Alternative( [
858                new KeywordMatcher( [ 'left', 'center', 'right', 'top', 'bottom' ] ),
859                $lp,
860                UnorderedGroup::allOf( [
861                    new KeywordMatcher( [ 'left', 'center', 'right' ] ),
862                    new KeywordMatcher( [ 'top', 'center', 'bottom' ] )
863                ] ),
864                new Juxtaposition( [
865                    new Alternative( [
866                        new KeywordMatcher( [ 'left', 'center', 'right' ] ),
867                        $lp
868                    ] ),
869                    new Alternative( [
870                        new KeywordMatcher( [ 'top', 'center', 'bottom' ] ),
871                        $lp
872                    ] ),
873                ] ),
874                UnorderedGroup::allOf( [
875                    new Juxtaposition( [
876                        new KeywordMatcher( [ 'left', 'right' ] ),
877                        $lp
878                    ] ),
879                    new Juxtaposition( [
880                        new KeywordMatcher( [ 'top', 'bottom' ] ),
881                        $lp
882                    ] ),
883                ] )
884            ] );
885        }
886        return $this->cache[__METHOD__];
887    }
888
889    /**
890     * Matcher for a bg-position value
891     * @see https://www.w3.org/TR/2024/CRD-css-backgrounds-3-20240311/#typedef-bg-position
892     * @return Matcher
893     */
894    public function bgPosition() {
895        if ( !isset( $this->cache[__METHOD__] ) ) {
896            $lp = $this->lengthPercentage();
897            $olp = Quantifier::optional( $lp );
898            $center = new KeywordMatcher( 'center' );
899            $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
900            $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
901
902            $this->cache[__METHOD__] = new Alternative( [
903                new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
904                new Juxtaposition( [
905                    new Alternative( [ $center, $leftRight, $lp ] ),
906                    new Alternative( [ $center, $topBottom, $lp ] ),
907                ] ),
908                UnorderedGroup::allOf( [
909                    new Alternative( [ $center, new Juxtaposition( [ $leftRight, $olp ] ) ] ),
910                    new Alternative( [ $center, new Juxtaposition( [ $topBottom, $olp ] ) ] ),
911                ] ),
912            ] );
913        }
914        return $this->cache[__METHOD__];
915    }
916
917    /**
918     * Matcher for a CSS media query
919     * @see https://www.w3.org/TR/2017/CR-mediaqueries-4-20170905/#mq-syntax
920     * Level 5 accessibility queries are also supported
921     * @see https://drafts.csswg.org/mediaqueries-5/#mf-user-preferences
922     * @param bool $strict Only allow defined query types
923     * @return Matcher
924     */
925    public function cssMediaQuery( $strict = true ) {
926        $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
927        if ( !isset( $this->cache[$key] ) ) {
928            if ( $strict ) {
929                $generalEnclosed = new NothingMatcher();
930
931                $mediaType = new KeywordMatcher( [
932                    'all', 'print', 'screen', 'speech',
933                    // deprecated
934                    'tty', 'tv', 'projection', 'handheld', 'braille', 'embossed', 'aural'
935                ] );
936
937                $rangeFeatures = [
938                    'width', 'height', 'aspect-ratio', 'resolution', 'color', 'color-index', 'monochrome',
939                    // deprecated
940                    'device-width', 'device-height', 'device-aspect-ratio'
941                ];
942                $discreteFeatures = [
943                    'orientation', 'scan', 'grid', 'update', 'overflow-block', 'overflow-inline', 'color-gamut',
944                    'pointer', 'hover', 'any-pointer', 'any-hover', 'scripting', 'prefers-color-scheme',
945                    'prefers-reduced-motion', 'prefers-reduced-transparency',
946                    'prefers-contrast', 'forced-colors'
947                ];
948                $mfName = new KeywordMatcher( array_merge(
949                    $rangeFeatures,
950                    array_map( static function ( $f ) {
951                        return "min-$f";
952                    }, $rangeFeatures ),
953                    array_map( static function ( $f ) {
954                        return "max-$f";
955                    }, $rangeFeatures ),
956                    $discreteFeatures
957                ) );
958            } else {
959                $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
960                $generalEnclosed = new Alternative( [
961                    new FunctionMatcher( null, $anythingPlus ),
962                    new BlockMatcher( Token::T_LEFT_PAREN,
963                        new Juxtaposition( [ $this->ident(), $anythingPlus ] )
964                    ),
965                ] );
966                $mediaType = $this->ident();
967                $mfName = $this->ident();
968            }
969
970            $posInt = $this->mathFunction(
971                new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
972                    return $t->typeFlag() === 'integer' && preg_match( '/^\+?\d+$/', $t->representation() );
973                } )
974            );
975            $eq = new DelimMatcher( '=' );
976            $oeq = Quantifier::optional( new Juxtaposition( [ new NoWhitespace, $eq ] ) );
977            $ltgteq = Quantifier::optional( new Alternative( [
978                $eq,
979                new Juxtaposition( [ new DelimMatcher( [ '<', '>' ] ), $oeq ] ),
980            ] ) );
981            $lteq = new Juxtaposition( [ new DelimMatcher( '<' ), $oeq ] );
982            $gteq = new Juxtaposition( [ new DelimMatcher( '>' ), $oeq ] );
983            $mfValue = new Alternative( [
984                $this->number(),
985                $this->dimension(),
986                $this->ident(),
987                new KeywordMatcher( [ 'light', 'dark' ] ),
988                new Juxtaposition( [ $posInt, new DelimMatcher( '/' ), $posInt ] ),
989            ] );
990
991            // temporary
992            $mediaInParens = new NothingMatcher();
993            $mediaNot = new Juxtaposition( [ new KeywordMatcher( 'not' ), &$mediaInParens ] );
994            $mediaAnd = new Juxtaposition( [ new KeywordMatcher( 'and' ), &$mediaInParens ] );
995            $mediaOr = new Juxtaposition( [ new KeywordMatcher( 'or' ), &$mediaInParens ] );
996            $mediaCondition = new Alternative( [
997                $mediaNot,
998                new Juxtaposition( [
999                    &$mediaInParens,
1000                    new Alternative( [
1001                        Quantifier::star( $mediaAnd ),
1002                        Quantifier::star( $mediaOr ),
1003                    ] )
1004                ] ),
1005            ] );
1006            $mediaConditionWithoutOr = new Alternative( [
1007                $mediaNot,
1008                new Juxtaposition( [ &$mediaInParens, Quantifier::star( $mediaAnd ) ] ),
1009            ] );
1010            $mediaFeature = new BlockMatcher( Token::T_LEFT_PAREN, new Alternative( [
1011                // <mf-plain>
1012                new Juxtaposition( [ $mfName, new TokenMatcher( Token::T_COLON ), $mfValue ] ),
1013                // <mf-boolean>
1014                $mfName,
1015                // <mf-range>, 1st alternative
1016                new Juxtaposition( [ $mfName, $ltgteq, $mfValue ] ),
1017                // <mf-range>, 2nd alternative
1018                new Juxtaposition( [ $mfValue, $ltgteq, $mfName ] ),
1019                // <mf-range>, 3rd alt
1020                new Juxtaposition( [ $mfValue, $lteq, $mfName, $lteq, $mfValue ] ),
1021                // <mf-range>, 4th alt
1022                new Juxtaposition( [ $mfValue, $gteq, $mfName, $gteq, $mfValue ] ),
1023            ] ) );
1024            $mediaInParens = new Alternative( [
1025                new BlockMatcher( Token::T_LEFT_PAREN, $mediaCondition ),
1026                $mediaFeature,
1027                $generalEnclosed,
1028            ] );
1029
1030            $this->cache[$key] = new Alternative( [
1031                $mediaCondition,
1032                new Juxtaposition( [
1033                    Quantifier::optional( new KeywordMatcher( [ 'not', 'only' ] ) ),
1034                    $mediaType,
1035                    Quantifier::optional( new Juxtaposition( [
1036                        new KeywordMatcher( 'and' ),
1037                        $mediaConditionWithoutOr,
1038                    ] ) )
1039                ] )
1040            ] );
1041        }
1042
1043        return $this->cache[$key];
1044    }
1045
1046    /**
1047     * Matcher for a CSS media query list
1048     * @see https://www.w3.org/TR/2017/CR-mediaqueries-4-20170905/#mq-syntax
1049     * @param bool $strict Only allow defined query types
1050     * @return Matcher
1051     */
1052    public function cssMediaQueryList( $strict = true ) {
1053        $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
1054        return $this->cache[$key]
1055            ??= Quantifier::hash( $this->cssMediaQuery( $strict ), 0, INF );
1056    }
1057
1058    /**
1059     * Matcher for a "supports-condition"
1060     * @see https://www.w3.org/TR/2013/CR-css3-conditional-20130404/#supports_condition
1061     * @param PropertySanitizer|null $declarationSanitizer Check declarations against this Sanitizer
1062     * @param bool $strict Only accept defined syntax. Default true.
1063     * @return Matcher
1064     */
1065    public function cssSupportsCondition(
1066        ?PropertySanitizer $declarationSanitizer = null, $strict = true
1067    ) {
1068        $ws = $this->significantWhitespace();
1069        $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
1070
1071        if ( $strict ) {
1072            $generalEnclosed = new NothingMatcher();
1073        } else {
1074            $generalEnclosed = new Alternative( [
1075                new FunctionMatcher( null, $anythingPlus ),
1076                new BlockMatcher( Token::T_LEFT_PAREN, new Juxtaposition( [ $this->ident(), $anythingPlus ] ) ),
1077            ] );
1078        }
1079
1080        // temp
1081        $supportsConditionBlock = new NothingMatcher();
1082        $supportsConditionInParens = new Alternative( [
1083            &$supportsConditionBlock,
1084            new BlockMatcher( Token::T_LEFT_PAREN, $this->cssDeclaration( $declarationSanitizer ) ),
1085            $generalEnclosed,
1086        ] );
1087        $supportsCondition = new Alternative( [
1088            new Juxtaposition( [ new KeywordMatcher( 'not' ), $ws, $supportsConditionInParens ] ),
1089            new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
1090                $ws, new KeywordMatcher( 'and' ), $ws, $supportsConditionInParens
1091            ] ) ) ] ),
1092            new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
1093                $ws, new KeywordMatcher( 'or' ), $ws, $supportsConditionInParens
1094            ] ) ) ] ),
1095            $supportsConditionInParens,
1096        ] );
1097        $supportsConditionBlock = new BlockMatcher( Token::T_LEFT_PAREN, $supportsCondition );
1098
1099        return $supportsCondition;
1100    }
1101
1102    /**
1103     * Matcher for a declaration
1104     * @param PropertySanitizer|null $declarationSanitizer Check declarations against this Sanitizer
1105     * @return Matcher
1106     */
1107    public function cssDeclaration( ?PropertySanitizer $declarationSanitizer = null ) {
1108        $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
1109
1110        return new CheckedMatcher(
1111            $anythingPlus,
1112            static function ( ComponentValueList $list, GrammarMatch $match, array $options )
1113                use ( $declarationSanitizer )
1114            {
1115                $cvlist = new ComponentValueList( $match->getValues() );
1116                $parser = Parser::newFromTokens( $cvlist->toTokenArray() );
1117                $declaration = $parser->parseDeclaration();
1118                if ( !$declaration || $parser->getParseErrors() ) {
1119                    return false;
1120                }
1121                if ( !$declarationSanitizer ) {
1122                    return true;
1123                }
1124                $reset = $declarationSanitizer->stashSanitizationErrors();
1125                $ret = $declarationSanitizer->sanitize( $declaration );
1126                $errors = $declarationSanitizer->getSanitizationErrors();
1127                unset( $reset );
1128                return $ret === $declaration && !$errors;
1129            }
1130        );
1131    }
1132
1133    /**
1134     * Matcher for single easing functions from CSS Easing Functions Level 1
1135     * @see https://www.w3.org/TR/2023/CRD-css-easing-1-20230213/#typedef-easing-function
1136     * @return Matcher
1137     */
1138    public function cssSingleEasingFunction() {
1139        return $this->cache[__METHOD__]
1140            ??= new Alternative( [
1141                new KeywordMatcher( [
1142                    'ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end'
1143                ] ),
1144                new FunctionMatcher( 'steps', new Juxtaposition( [
1145                    $this->integer(),
1146                    Quantifier::optional( new KeywordMatcher( [
1147                        'jump-start', 'jump-end', 'jump-none', 'jump-both', 'start', 'end'
1148                    ] ) ),
1149                ], true ) ),
1150                new FunctionMatcher( 'cubic-bezier', Quantifier::hash( $this->number(), 4, 4 ) ),
1151            ] );
1152    }
1153
1154    /**
1155     * Matcher for <counter-style>
1156     * @see https://www.w3.org/TR/2021/CR-css-counter-styles-3-20210727/#typedef-counter-style
1157     * @return Matcher
1158     */
1159    public function counterStyle() {
1160        return $this->cache[__METHOD__] ??= new Alternative( [
1161            $this->customIdent( [ 'none' ] ),
1162            new FunctionMatcher(
1163                'symbols',
1164                // "If the system is alphabetic or numeric, there must be at least two
1165                // <string>s or <image>s, or else the function is invalid."
1166                // Implement that by modifying the grammar
1167                new Alternative( [
1168                    new Juxtaposition( [
1169                        new KeywordMatcher( [ 'numeric', 'alphabetic' ] ),
1170                        Quantifier::count(
1171                            new Alternative( [
1172                                $this->string(),
1173                                $this->image()
1174                            ] ),
1175                            2, INF
1176                        )
1177                    ] ),
1178                    new Juxtaposition( [
1179                        Quantifier::optional( new KeywordMatcher( [
1180                            'cyclic', 'symbolic', 'fixed'
1181                        ] ) ),
1182                        Quantifier::plus(
1183                            new Alternative( [
1184                                $this->string(),
1185                                $this->image()
1186                            ] )
1187                        )
1188                    ] )
1189                ] )
1190            )
1191        ] );
1192    }
1193
1194    /***************************************************************************/
1195    // region   CSS Selectors Level 3
1196    /**
1197     * @name   CSS Selectors Level 3
1198     * https://www.w3.org/TR/2018/REC-selectors-3-20181106/#w3cselgrammar
1199     */
1200
1201    /**
1202     * List of selectors (selectors_group)
1203     *
1204     *     selector [ COMMA S* selector ]*
1205     *
1206     * Capturing is set up for the `selector`s.
1207     *
1208     * @return Matcher
1209     */
1210    public function cssSelectorList() {
1211        if ( !isset( $this->cache[__METHOD__] ) ) {
1212            // Technically the spec doesn't allow whitespace before the comma,
1213            // but I'd guess every browser does. So just use Quantifier::hash.
1214            $selector = $this->cssSelector()->capture( 'selector' );
1215            $this->cache[__METHOD__] = Quantifier::hash( $selector );
1216            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1217        }
1218        return $this->cache[__METHOD__];
1219    }
1220
1221    /**
1222     * A single selector (selector)
1223     *
1224     *     simple_selector_sequence [ combinator simple_selector_sequence ]*
1225     *
1226     * Capturing is set up for the `simple_selector_sequence`s (as 'simple') and `combinator`.
1227     *
1228     * @return Matcher
1229     */
1230    public function cssSelector() {
1231        if ( !isset( $this->cache[__METHOD__] ) ) {
1232            $simple = $this->cssSimpleSelectorSeq()->capture( 'simple' );
1233            $this->cache[__METHOD__] = new Juxtaposition( [
1234                $simple,
1235                Quantifier::star( new Juxtaposition( [
1236                    $this->cssCombinator()->capture( 'combinator' ),
1237                    $simple,
1238                ] ) )
1239            ] );
1240            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1241        }
1242        return $this->cache[__METHOD__];
1243    }
1244
1245    /**
1246     * A CSS combinator (combinator)
1247     *
1248     *     PLUS S* | GREATER S* | TILDE S* | S+
1249     *
1250     * (combinators can be surrounded by whitespace)
1251     *
1252     * @return Matcher
1253     */
1254    public function cssCombinator() {
1255        if ( !isset( $this->cache[__METHOD__] ) ) {
1256            $this->cache[__METHOD__] = new Alternative( [
1257                new Juxtaposition( [
1258                    $this->optionalWhitespace(),
1259                    new DelimMatcher( [ '+', '>', '~' ] ),
1260                    $this->optionalWhitespace(),
1261                ] ),
1262                $this->significantWhitespace(),
1263            ] );
1264            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1265        }
1266        return $this->cache[__METHOD__];
1267    }
1268
1269    /**
1270     * A simple selector sequence (simple_selector_sequence)
1271     *
1272     *     [ type_selector | universal ]
1273     *     [ HASH | class | attrib | pseudo | negation ]*
1274     *     | [ HASH | class | attrib | pseudo | negation ]+
1275     *
1276     * The following captures are set:
1277     *  - element: [ type_selector | universal ]
1278     *  - id: HASH
1279     *  - class: class
1280     *  - attrib: attrib
1281     *  - pseudo: pseudo
1282     *  - negation: negation
1283     *
1284     * @return Matcher
1285     */
1286    public function cssSimpleSelectorSeq() {
1287        if ( !isset( $this->cache[__METHOD__] ) ) {
1288            $hashEtc = new Alternative( [
1289                $this->cssID()->capture( 'id' ),
1290                $this->cssClass()->capture( 'class' ),
1291                $this->cssAttrib()->capture( 'attrib' ),
1292                $this->cssPseudo()->capture( 'pseudo' ),
1293                $this->cssNegation()->capture( 'negation' ),
1294            ] );
1295
1296            $this->cache[__METHOD__] = new Alternative( [
1297                new Juxtaposition( [
1298                    Alternative::create( [
1299                        $this->cssTypeSelector(),
1300                        $this->cssUniversal(),
1301                    ] )->capture( 'element' ),
1302                    Quantifier::star( $hashEtc )
1303                ] ),
1304                Quantifier::plus( $hashEtc )
1305            ] );
1306            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1307        }
1308        return $this->cache[__METHOD__];
1309    }
1310
1311    /**
1312     * A type selector, i.e. a tag name (type_selector)
1313     *
1314     *     [ namespace_prefix ] ? element_name
1315     *
1316     * where element_name is
1317     *
1318     *     IDENT
1319     *
1320     * @return Matcher
1321     */
1322    public function cssTypeSelector() {
1323        if ( !isset( $this->cache[__METHOD__] ) ) {
1324            $this->cache[__METHOD__] = new Juxtaposition( [
1325                $this->cssOptionalNamespacePrefix(),
1326                new TokenMatcher( Token::T_IDENT )
1327            ] );
1328            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1329        }
1330        return $this->cache[__METHOD__];
1331    }
1332
1333    /**
1334     * A namespace prefix (namespace_prefix)
1335     *
1336     *      [ IDENT | '*' ]? '|'
1337     *
1338     * @return Matcher
1339     */
1340    public function cssNamespacePrefix() {
1341        if ( !isset( $this->cache[__METHOD__] ) ) {
1342            $this->cache[__METHOD__] = new Juxtaposition( [
1343                Quantifier::optional( new Alternative( [
1344                    $this->ident(),
1345                    new DelimMatcher( [ '*' ] ),
1346                ] ) ),
1347                new DelimMatcher( [ '|' ] ),
1348            ] );
1349            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1350        }
1351        return $this->cache[__METHOD__];
1352    }
1353
1354    /**
1355     * An optional namespace prefix
1356     *
1357     *     [ namespace_prefix ]?
1358     *
1359     * @return Matcher
1360     */
1361    private function cssOptionalNamespacePrefix() {
1362        if ( !isset( $this->cache[__METHOD__] ) ) {
1363            $this->cache[__METHOD__] = Quantifier::optional( $this->cssNamespacePrefix() );
1364            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1365        }
1366        return $this->cache[__METHOD__];
1367    }
1368
1369    /**
1370     * The universal selector (universal)
1371     *
1372     *     [ namespace_prefix ]? '*'
1373     *
1374     * @return Matcher
1375     */
1376    public function cssUniversal() {
1377        if ( !isset( $this->cache[__METHOD__] ) ) {
1378            $this->cache[__METHOD__] = new Juxtaposition( [
1379                $this->cssOptionalNamespacePrefix(),
1380                new DelimMatcher( [ '*' ] )
1381            ] );
1382            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1383        }
1384        return $this->cache[__METHOD__];
1385    }
1386
1387    /**
1388     * An ID selector
1389     *
1390     *     HASH
1391     *
1392     * @return Matcher
1393     */
1394    public function cssID() {
1395        if ( !isset( $this->cache[__METHOD__] ) ) {
1396            $this->cache[__METHOD__] = new TokenMatcher( Token::T_HASH, static function ( Token $t ) {
1397                return $t->typeFlag() === 'id';
1398            } );
1399            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1400        }
1401        return $this->cache[__METHOD__];
1402    }
1403
1404    /**
1405     * A class selector (class)
1406     *
1407     *     '.' IDENT
1408     *
1409     * @return Matcher
1410     */
1411    public function cssClass() {
1412        if ( !isset( $this->cache[__METHOD__] ) ) {
1413            $this->cache[__METHOD__] = new Juxtaposition( [
1414                new DelimMatcher( [ '.' ] ),
1415                $this->ident()
1416            ] );
1417            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1418        }
1419        return $this->cache[__METHOD__];
1420    }
1421
1422    /**
1423     * An attribute selector (attrib)
1424     *
1425     *     '[' S* [ namespace_prefix ]? IDENT S*
1426     *         [ [ PREFIXMATCH |
1427     *             SUFFIXMATCH |
1428     *             SUBSTRINGMATCH |
1429     *             '=' |
1430     *             INCLUDES |
1431     *             DASHMATCH ] S* [ IDENT | STRING ] S*
1432     *         ]? ']'
1433     *
1434     * Captures are set for the attribute, test, and value. Note that these
1435     * captures will probably be relative to the contents of the SimpleBlock
1436     * that this matcher matches!
1437     *
1438     * @return Matcher
1439     */
1440    public function cssAttrib() {
1441        if ( !isset( $this->cache[__METHOD__] ) ) {
1442            // An attribute is going to be parsed by the parser as a
1443            // SimpleBlock, so that's what we need to look for here.
1444
1445            $this->cache[__METHOD__] = new BlockMatcher( Token::T_LEFT_BRACKET,
1446                new Juxtaposition( [
1447                    $this->optionalWhitespace(),
1448                    Juxtaposition::create( [
1449                        $this->cssOptionalNamespacePrefix(),
1450                        $this->ident(),
1451                    ] )->capture( 'attribute' ),
1452                    $this->optionalWhitespace(),
1453                    Quantifier::optional( new Juxtaposition( [
1454                        // Sigh. They removed various tokens from CSS Syntax 3, but didn't update the grammar
1455                        // in CSS Selectors 3. Wing it with a hint from CSS Selectors 4's <attr-matcher>
1456                        ( new Juxtaposition( [
1457                            Quantifier::optional( new DelimMatcher( [ '^', '$', '*', '~', '|' ] ) ),
1458                            new DelimMatcher( [ '=' ] ),
1459                        ] ) )->capture( 'test' ),
1460                        $this->optionalWhitespace(),
1461                        Alternative::create( [
1462                            $this->ident(),
1463                            $this->string(),
1464                        ] )->capture( 'value' ),
1465                        $this->optionalWhitespace(),
1466                    ] ) ),
1467                ] )
1468            );
1469            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1470        }
1471        return $this->cache[__METHOD__];
1472    }
1473
1474    /**
1475     * A pseudo-class or pseudo-element (pseudo)
1476     *
1477     *     ':' ':'? [ IDENT | functional_pseudo ]
1478     *
1479     * Where functional_pseudo is
1480     *
1481     *     FUNCTION S* expression ')'
1482     *
1483     * Although this actually only matches the pseudo-selectors defined in the
1484     * following sources:
1485     * - https://www.w3.org/TR/2018/REC-selectors-3-20181106/#pseudo-classes
1486     * - https://www.w3.org/TR/2022/WD-css-pseudo-4-20221230/
1487     * - https://www.w3.org/TR/2022/WD-selectors-4-20221111/#the-dir-pseudo
1488     *
1489     * @return Matcher
1490     */
1491    public function cssPseudo() {
1492        if ( !isset( $this->cache[__METHOD__] ) ) {
1493            $colon = new TokenMatcher( Token::T_COLON );
1494            $ows = $this->optionalWhitespace();
1495            $anplusb = new Juxtaposition( [ $ows, $this->cssANplusB(), $ows ] );
1496            $dirValues = new KeywordMatcher( [ 'ltr', 'rtl' ] );
1497            $this->cache[__METHOD__] = new Alternative( [
1498                new Juxtaposition( [
1499                    $colon,
1500                    new Alternative( [
1501                        new KeywordMatcher( [
1502                            'link', 'visited', 'hover', 'active', 'focus', 'target', 'enabled', 'disabled', 'checked',
1503                            'indeterminate', 'root', 'first-child', 'last-child', 'first-of-type',
1504                            'last-of-type', 'only-child', 'only-of-type', 'empty',
1505                            // CSS2-compat elements with class syntax
1506                            'first-line', 'first-letter', 'before', 'after',
1507                        ] ),
1508                        new FunctionMatcher( 'lang', new Juxtaposition( [ $ows, $this->ident(), $ows ] ) ),
1509                        new FunctionMatcher( 'dir', new Juxtaposition( [ $ows, $dirValues, $ows ] ) ),
1510                        new FunctionMatcher( 'nth-child', $anplusb ),
1511                        new FunctionMatcher( 'nth-last-child', $anplusb ),
1512                        new FunctionMatcher( 'nth-of-type', $anplusb ),
1513                        new FunctionMatcher( 'nth-last-of-type', $anplusb ),
1514                    ] ),
1515                ] ),
1516                new Juxtaposition( [
1517                    $colon,
1518                    $colon,
1519                    new Alternative( [
1520                        new Juxtaposition( [
1521                            new KeywordMatcher( 'first-letter' ),
1522                            $colon,
1523                            $colon,
1524                            new KeywordMatcher( [ 'prefix', 'postfix' ] ),
1525                        ] ),
1526                        new KeywordMatcher( [
1527                            'first-line', 'first-letter', 'before', 'after', 'selection', 'target-text',
1528                            'spelling-error', 'grammar-error', 'marker', 'placeholder',
1529                            'file-selector-button',
1530                        ] ),
1531                    ] ),
1532                ] ),
1533            ] );
1534            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1535        }
1536        return $this->cache[__METHOD__];
1537    }
1538
1539    /**
1540     * An "AN+B" form
1541     *
1542     * https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#anb-microsyntax
1543     *
1544     * @return Matcher
1545     */
1546    public function cssANplusB() {
1547        if ( !isset( $this->cache[__METHOD__] ) ) {
1548            // Quoth the spec:
1549            // > The An+B notation was originally defined using a slightly
1550            // > different tokenizer than the rest of CSS, resulting in a
1551            // > somewhat odd definition when expressed in terms of CSS tokens.
1552            // That's a bit of an understatement
1553
1554            $plusQ = Quantifier::optional( new DelimMatcher( [ '+' ] ) );
1555            $n = new KeywordMatcher( [ 'n' ] );
1556            $dashN = new KeywordMatcher( [ '-n' ] );
1557            $nDash = new KeywordMatcher( [ 'n-' ] );
1558            $plusQN = new Juxtaposition( [ $plusQ, $n ] );
1559            $plusQNDash = new Juxtaposition( [ $plusQ, $nDash ] );
1560            $nDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1561                return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n' );
1562            } );
1563            $nDashDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1564                return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n-' );
1565            } );
1566            $nDashDigitDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1567                return $t->typeFlag() === 'integer' && preg_match( '/^n-\d+$/i', $t->unit() );
1568            } );
1569            $nDashDigitIdent = new TokenMatcher( Token::T_IDENT, static function ( Token $t ) {
1570                return preg_match( '/^n-\d+$/i', $t->value() );
1571            } );
1572            $dashNDashDigitIdent = new TokenMatcher( Token::T_IDENT, static function ( Token $t ) {
1573                return preg_match( '/^-n-\d+$/i', $t->value() );
1574            } );
1575            $signedInt = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1576                return $t->typeFlag() === 'integer' && preg_match( '/^[+-]/', $t->representation() );
1577            } );
1578            $signlessInt = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1579                return $t->typeFlag() === 'integer' && preg_match( '/^\d/', $t->representation() );
1580            } );
1581            $plusOrMinus = new DelimMatcher( [ '+', '-' ] );
1582            $S = $this->optionalWhitespace();
1583
1584            $this->cache[__METHOD__] = new Alternative( [
1585                new KeywordMatcher( [ 'odd', 'even' ] ),
1586                new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1587                    return $t->typeFlag() === 'integer';
1588                } ),
1589                $nDimension,
1590                $plusQN,
1591                $dashN,
1592                $nDashDigitDimension,
1593                new Juxtaposition( [ $plusQ, $nDashDigitIdent ] ),
1594                $dashNDashDigitIdent,
1595                new Juxtaposition( [ $nDimension, $S, $signedInt ] ),
1596                new Juxtaposition( [ $plusQN, $S, $signedInt ] ),
1597                new Juxtaposition( [ $dashN, $S, $signedInt ] ),
1598                new Juxtaposition( [ $nDashDimension, $S, $signlessInt ] ),
1599                new Juxtaposition( [ $plusQNDash, $S, $signlessInt ] ),
1600                new Juxtaposition( [ new KeywordMatcher( [ '-n-' ] ), $S, $signlessInt ] ),
1601                new Juxtaposition( [ $nDimension, $S, $plusOrMinus, $S, $signlessInt ] ),
1602                new Juxtaposition( [ $plusQN, $S, $plusOrMinus, $S, $signlessInt ] ),
1603                new Juxtaposition( [ $dashN, $S, $plusOrMinus, $S, $signlessInt ] )
1604            ] );
1605            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1606        }
1607        return $this->cache[__METHOD__];
1608    }
1609
1610    /**
1611     * A negation (negation)
1612     *
1613     *     ':' not( S* [ type_selector | universal | HASH | class | attrib | pseudo ] S* ')'
1614     *
1615     * @return Matcher
1616     */
1617    public function cssNegation() {
1618        if ( !isset( $this->cache[__METHOD__] ) ) {
1619            // A negation is going to be parsed by the parser as a colon
1620            // followed by a CSSFunction, so that's what we need to look for
1621            // here.
1622
1623            $this->cache[__METHOD__] = new Juxtaposition( [
1624                new TokenMatcher( Token::T_COLON ),
1625                new FunctionMatcher( 'not',
1626                    new Juxtaposition( [
1627                        $this->optionalWhitespace(),
1628                        new Alternative( [
1629                            $this->cssTypeSelector(),
1630                            $this->cssUniversal(),
1631                            $this->cssID(),
1632                            $this->cssClass(),
1633                            $this->cssAttrib(),
1634                            $this->cssPseudo(),
1635                        ] ),
1636                        $this->optionalWhitespace(),
1637                    ] )
1638                )
1639            ] );
1640            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1641        }
1642        return $this->cache[__METHOD__];
1643    }
1644
1645    /**
1646     * @return KeywordMatcher
1647     */
1648    public function colorWords(): KeywordMatcher {
1649        return $this->cache[__METHOD__]
1650            ??= new KeywordMatcher( [
1651                // Basic colors
1652                'aqua', 'black', 'blue', 'fuchsia', 'gray', 'green',
1653                'lime', 'maroon', 'navy', 'olive', 'purple', 'red',
1654                'silver', 'teal', 'white', 'yellow',
1655                // Extended colors
1656                'aliceblue', 'antiquewhite', 'aquamarine', 'azure',
1657                'beige', 'bisque', 'blanchedalmond', 'blueviolet', 'brown',
1658                'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
1659                'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
1660                'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
1661                'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
1662                'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
1663                'darksalmon', 'darkseagreen', 'darkslateblue',
1664                'darkslategray', 'darkslategrey', 'darkturquoise',
1665                'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
1666                'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
1667                'forestgreen', 'gainsboro', 'ghostwhite', 'gold',
1668                'goldenrod', 'greenyellow', 'grey', 'honeydew', 'hotpink',
1669                'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
1670                'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
1671                'lightcoral', 'lightcyan', 'lightgoldenrodyellow',
1672                'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
1673                'lightsalmon', 'lightseagreen', 'lightskyblue',
1674                'lightslategray', 'lightslategrey', 'lightsteelblue',
1675                'lightyellow', 'limegreen', 'linen', 'magenta',
1676                'mediumaquamarine', 'mediumblue', 'mediumorchid',
1677                'mediumpurple', 'mediumseagreen', 'mediumslateblue',
1678                'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
1679                'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
1680                'navajowhite', 'oldlace', 'olivedrab', 'orange',
1681                'orangered', 'orchid', 'palegoldenrod', 'palegreen',
1682                'paleturquoise', 'palevioletred', 'papayawhip',
1683                'peachpuff', 'peru', 'pink', 'plum', 'powderblue',
1684                'rebeccapurple', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
1685                'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue',
1686                'slateblue', 'slategray', 'slategrey', 'snow',
1687                'springgreen', 'steelblue', 'tan', 'thistle', 'tomato',
1688                'turquoise', 'violet', 'wheat', 'whitesmoke',
1689                'yellowgreen',
1690                // Other keywords
1691                'transparent', 'currentColor',
1692                // System colors
1693                'AccentColor', 'AccentColorText', 'ActiveText',
1694                'ButtonBorder', 'ButtonFace', 'ButtonText',
1695                'Canvas', 'CanvasText', 'Field', 'FieldText',
1696                'GrayText', 'Highlight', 'HighlightText',
1697                'LinkText', 'Mark', 'MarkText',
1698                'SelectedItem', 'SelectedItemText', 'VisitedText',
1699            ] );
1700    }
1701
1702    // endregion -- end of CSS Selectors Level 3
1703
1704}
1705
1706/*
1707 * This file uses VisualStudio style region/endregion fold markers which are
1708 * recognised by PHPStorm. If modelines are enabled, the following editor
1709 * configuration will also enable folding in vim, if it is in the last 5 lines
1710 * of the file. We also use "@name" which creates sections in Doxygen.
1711 *
1712 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker
1713 */