Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
1494 / 1494
100.00% covered (success)
100.00%
38 / 38
CRAP
100.00% covered (success)
100.00%
1 / 1
StylePropertySanitizer
100.00% covered (success)
100.00%
1494 / 1494
100.00% covered (success)
100.00%
38 / 38
76
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
1
 css2
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
2
 cssDisplay3
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 cssPosition3
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 cssColor3
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 backgroundTypes
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 boxEdgeKeywords
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 cssBackgrounds3
100.00% covered (success)
100.00%
125 / 125
100.00% covered (success)
100.00%
1 / 1
2
 cssImages3
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 cssFonts3
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
2
 cssMulticol
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 cssOverflow3
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 cssOverflow4
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
2
 cssUI4
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
2
 cssCompositing1
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 cssWritingModes4
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 cssTransitions
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 cssAnimations
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
2
 cssFlexbox3
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 transformFunc
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
3
 cssTransforms1
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 cssTransforms2
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
2
 cssText3
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
2
 cssTextDecor3
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
2
 cssAlign3
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
1 / 1
2
 cssBreak3
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 cssGrid1
100.00% covered (success)
100.00%
156 / 156
100.00% covered (success)
100.00%
1 / 1
3
 cssFilter1
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 basicShapes
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
2
 cssShapes1
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 cssMasking1
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
1 / 1
2
 getSizingAdditions3
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getSizingAdditions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 cssSizing4
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
2
 cssLogical1
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
2
 cssRuby1
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 cssLists3
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 cssScrollSnap1
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare( strict_types = 1 );
3
4/**
5 * @file
6 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
7 */
8
9namespace Wikimedia\CSS\Sanitizer;
10
11use Wikimedia\CSS\Grammar\Alternative;
12use Wikimedia\CSS\Grammar\BlockMatcher;
13use Wikimedia\CSS\Grammar\DelimMatcher;
14use Wikimedia\CSS\Grammar\FunctionMatcher;
15use Wikimedia\CSS\Grammar\Juxtaposition;
16use Wikimedia\CSS\Grammar\KeywordMatcher;
17use Wikimedia\CSS\Grammar\Matcher;
18use Wikimedia\CSS\Grammar\MatcherFactory;
19use Wikimedia\CSS\Grammar\Quantifier;
20use Wikimedia\CSS\Grammar\TokenMatcher;
21use Wikimedia\CSS\Grammar\UnorderedGroup;
22use Wikimedia\CSS\Objects\Token;
23
24/**
25 * Sanitizes a Declaration representing a CSS style property
26 * @note This intentionally doesn't support
27 *  [cascading variables](https://www.w3.org/TR/css-variables/) since that
28 *  seems impossible to securely sanitize.
29 */
30class StylePropertySanitizer extends PropertySanitizer {
31
32    /** @var mixed[] */
33    protected $cache = [];
34
35    /**
36     * @param MatcherFactory $matcherFactory Factory for Matchers
37     */
38    public function __construct( MatcherFactory $matcherFactory ) {
39        parent::__construct( [], $matcherFactory->cssWideKeywords() );
40
41        $this->addKnownProperties( [
42            // https://www.w3.org/TR/2022/CR-css-cascade-4-20220113/#all-shorthand
43            'all' => $matcherFactory->cssWideKeywords(),
44
45            // https://www.w3.org/TR/2019/REC-pointerevents2-20190404/#the-touch-action-css-property
46            'touch-action' => new Alternative( [
47                new KeywordMatcher( [ 'auto', 'none', 'manipulation' ] ),
48                UnorderedGroup::someOf( [
49                    new KeywordMatcher( 'pan-x' ),
50                    new KeywordMatcher( 'pan-y' ),
51                ] ),
52            ] ),
53
54            // https://www.w3.org/TR/2023/WD-css-page-3-20230914/#using-named-pages
55            'page' => $matcherFactory->ident(),
56        ] );
57        $this->addKnownProperties( $this->css2( $matcherFactory ) );
58        $this->addKnownProperties( $this->cssDisplay3( $matcherFactory ) );
59        $this->addKnownProperties( $this->cssPosition3( $matcherFactory ) );
60        $this->addKnownProperties( $this->cssColor3( $matcherFactory ) );
61        $this->addKnownProperties( $this->cssBackgrounds3( $matcherFactory ) );
62        $this->addKnownProperties( $this->cssImages3( $matcherFactory ) );
63        $this->addKnownProperties( $this->cssFonts3( $matcherFactory ) );
64        $this->addKnownProperties( $this->cssMulticol( $matcherFactory ) );
65        $this->addKnownProperties( $this->cssOverflow4( $matcherFactory ) );
66        $this->addKnownProperties( $this->cssUI4( $matcherFactory ) );
67        $this->addKnownProperties( $this->cssCompositing1( $matcherFactory ) );
68        $this->addKnownProperties( $this->cssWritingModes4( $matcherFactory ) );
69        $this->addKnownProperties( $this->cssTransitions( $matcherFactory ) );
70        $this->addKnownProperties( $this->cssAnimations( $matcherFactory ) );
71        $this->addKnownProperties( $this->cssFlexbox3( $matcherFactory ) );
72        $this->addKnownProperties( $this->cssTransforms2( $matcherFactory ) );
73        $this->addKnownProperties( $this->cssText3( $matcherFactory ) );
74        $this->addKnownProperties( $this->cssTextDecor3( $matcherFactory ) );
75        $this->addKnownProperties( $this->cssAlign3( $matcherFactory ) );
76        $this->addKnownProperties( $this->cssBreak3( $matcherFactory ) );
77        $this->addKnownProperties( $this->cssGrid1( $matcherFactory ) );
78        $this->addKnownProperties( $this->cssFilter1( $matcherFactory ) );
79        $this->addKnownProperties( $this->cssShapes1( $matcherFactory ) );
80        $this->addKnownProperties( $this->cssMasking1( $matcherFactory ) );
81        $this->addKnownProperties( $this->cssSizing4( $matcherFactory ) );
82        $this->addKnownProperties( $this->cssLogical1( $matcherFactory ) );
83        $this->addKnownProperties( $this->cssRuby1( $matcherFactory ) );
84        $this->addKnownProperties( $this->cssLists3( $matcherFactory ) );
85        $this->addKnownProperties( $this->cssScrollSnap1( $matcherFactory ) );
86    }
87
88    /**
89     * Properties from CSS 2.1
90     * @see https://www.w3.org/TR/2011/REC-CSS2-20110607/
91     * @note Omits properties that have been replaced by a CSS3 module
92     * @param MatcherFactory $matcherFactory Factory for Matchers
93     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
94     */
95    protected function css2( MatcherFactory $matcherFactory ) {
96        // @codeCoverageIgnoreStart
97        if ( isset( $this->cache[__METHOD__] ) ) {
98            return $this->cache[__METHOD__];
99        }
100        // @codeCoverageIgnoreEnd
101
102        $props = [];
103
104        $none = new KeywordMatcher( 'none' );
105        $auto = new KeywordMatcher( 'auto' );
106        $autoLength = new Alternative( [ $auto, $matcherFactory->length() ] );
107        $autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
108
109        // https://www.w3.org/TR/2011/REC-CSS2-20110607/box.html
110        $props['margin-top'] = $autoLengthPct;
111        $props['margin-bottom'] = $autoLengthPct;
112        $props['margin-left'] = $autoLengthPct;
113        $props['margin-right'] = $autoLengthPct;
114        $props['margin'] = Quantifier::count( $autoLengthPct, 1, 4 );
115        $props['padding-top'] = $matcherFactory->lengthPercentage();
116        $props['padding-bottom'] = $matcherFactory->lengthPercentage();
117        $props['padding-left'] = $matcherFactory->lengthPercentage();
118        $props['padding-right'] = $matcherFactory->lengthPercentage();
119        $props['padding'] = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
120
121        // https://www.w3.org/TR/2011/REC-CSS2-20110607/visuren.html
122        // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#directional-keywords
123        $props['z-index'] = new Alternative( [ $auto, $matcherFactory->integer() ] );
124        $props['float'] = new KeywordMatcher( [ 'left', 'right', 'inline-start', 'inline-end', 'none' ] );
125        $props['clear'] = new KeywordMatcher( [ 'none', 'left', 'right', 'both', 'inline-start', 'inline-end' ] );
126
127        // https://www.w3.org/TR/2011/REC-CSS2-20110607/visudet.html
128        $props['line-height'] = new Alternative( [
129            new KeywordMatcher( 'normal' ),
130            $matcherFactory->length(),
131            $matcherFactory->numberPercentage(),
132        ] );
133        $props['vertical-align'] = new Alternative( [
134            new KeywordMatcher( [
135                'baseline', 'sub', 'super', 'top', 'text-top', 'middle', 'bottom', 'text-bottom'
136            ] ),
137            $matcherFactory->lengthPercentage(),
138        ] );
139
140        // https://www.w3.org/TR/2011/REC-CSS2-20110607/visufx.html
141        $props['clip'] = new Alternative( [
142            $auto, new FunctionMatcher( 'rect', Quantifier::hash( $autoLength, 4, 4 ) ),
143        ] );
144
145        // https://www.w3.org/TR/2011/REC-CSS2-20110607/generate.html
146        $props['content'] = new Alternative( [
147            new KeywordMatcher( [ 'normal', 'none' ] ),
148            Quantifier::plus( new Alternative( [
149                $matcherFactory->string(),
150                // Replaces <url> per https://www.w3.org/TR/css-images-3/#placement
151                $matcherFactory->image(),
152                // Updated by https://www.w3.org/TR/2020/WD-css-lists-3-20201117/#counter-functions
153                new FunctionMatcher( 'counter', new Juxtaposition( [
154                    $matcherFactory->ident(),
155                    Quantifier::optional( $matcherFactory->counterStyle() ),
156                ], true ) ),
157                new FunctionMatcher( 'counters', new Juxtaposition( [
158                    $matcherFactory->ident(),
159                    $matcherFactory->string(),
160                    Quantifier::optional( $matcherFactory->counterStyle() ),
161                ], true ) ),
162                new FunctionMatcher( 'attr', $matcherFactory->ident() ),
163                new KeywordMatcher( [ 'open-quote', 'close-quote', 'no-open-quote', 'no-close-quote' ] ),
164            ] ) )
165        ] );
166        $props['quotes'] = new Alternative( [
167            $none, Quantifier::plus( new Juxtaposition( [
168                $matcherFactory->string(), $matcherFactory->string()
169            ] ) ),
170        ] );
171
172        // https://www.w3.org/TR/2011/REC-CSS2-20110607/tables.html
173        $props['caption-side'] = new KeywordMatcher( [ 'top', 'bottom' ] );
174        $props['table-layout'] = new KeywordMatcher( [ 'auto', 'fixed' ] );
175        $props['border-collapse'] = new KeywordMatcher( [ 'collapse', 'separate' ] );
176        $props['border-spacing'] = Quantifier::count( $matcherFactory->length(), 1, 2 );
177        $props['empty-cells'] = new KeywordMatcher( [ 'show', 'hide' ] );
178
179        $this->cache[__METHOD__] = $props;
180        return $props;
181    }
182
183    /**
184     * Properties for CSS Display Module Level 3
185     * @see https://www.w3.org/TR/2023/CR-css-display-3-20230330/
186     * @param MatcherFactory $matcherFactory Factory for Matchers
187     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
188     */
189    protected function cssDisplay3( MatcherFactory $matcherFactory ) {
190        // @codeCoverageIgnoreStart
191        if ( isset( $this->cache[__METHOD__] ) ) {
192            return $this->cache[__METHOD__];
193        }
194        // @codeCoverageIgnoreEnd
195
196        $props = [];
197
198        $displayOutside = new KeywordMatcher( [ 'block', 'inline', 'run-in' ] );
199
200        $props['display'] = new Alternative( [
201            // <display-outside> || <display-inside>
202            UnorderedGroup::someOf( [
203                $displayOutside,
204                new KeywordMatcher( [ 'flow', 'flow-root', 'table', 'flex', 'grid', 'ruby' ] ),
205            ] ),
206            // <display-listitem>
207            UnorderedGroup::allOf( [
208                Quantifier::optional( $displayOutside ),
209                Quantifier::optional( new KeywordMatcher( [ 'flow', 'flow-root' ] ) ),
210                new KeywordMatcher( 'list-item' ),
211            ] ),
212            new KeywordMatcher( [
213                // <display-internal>
214                'table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-cell',
215                'table-column-group', 'table-column', 'table-caption', 'ruby-base', 'ruby-text',
216                'ruby-base-container', 'ruby-text-container',
217                // <display-box>
218                'contents', 'none',
219                // <display-legacy>
220                'inline-block', 'inline-table', 'inline-flex', 'inline-grid',
221            ] ),
222        ] );
223
224        $props['visibility'] = new KeywordMatcher( [ 'visible', 'hidden', 'collapse' ] );
225
226        $props['order'] = $matcherFactory->integer();
227
228        $this->cache[__METHOD__] = $props;
229        return $props;
230    }
231
232    /**
233     * Properties for CSS Positioned Layout Module Level 3
234     * @see https://www.w3.org/TR/2025/WD-css-position-3-20250311/
235     * @param MatcherFactory $matcherFactory Factory for Matchers
236     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
237     */
238    protected function cssPosition3( MatcherFactory $matcherFactory ) {
239        // @codeCoverageIgnoreStart
240        if ( isset( $this->cache[__METHOD__] ) ) {
241            return $this->cache[__METHOD__];
242        }
243        // @codeCoverageIgnoreEnd
244
245        $auto = new KeywordMatcher( 'auto' );
246        $autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
247
248        $props = [];
249
250        $props['position'] = new KeywordMatcher( [
251            'static', 'relative', 'absolute', 'sticky', 'fixed'
252        ] );
253        $props['top'] = $autoLengthPct;
254        $props['right'] = $autoLengthPct;
255        $props['bottom'] = $autoLengthPct;
256        $props['left'] = $autoLengthPct;
257        $props['inset-block-start'] = $autoLengthPct;
258        $props['inset-inline-start'] = $autoLengthPct;
259        $props['inset-block-end'] = $autoLengthPct;
260        $props['inset-inline-end'] = $autoLengthPct;
261        $props['inset-block'] = Quantifier::count( $autoLengthPct, 1, 2 );
262        $props['inset-inline'] = $props['inset-block'];
263        $props['inset'] = Quantifier::count( $autoLengthPct, 1, 4 );
264
265        $this->cache[__METHOD__] = $props;
266        return $props;
267    }
268
269    /**
270     * Properties for CSS Color Module Level 3
271     * @see https://www.w3.org/TR/2018/REC-css-color-3-20180619/
272     * @param MatcherFactory $matcherFactory Factory for Matchers
273     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
274     */
275    protected function cssColor3( MatcherFactory $matcherFactory ) {
276        // @codeCoverageIgnoreStart
277        if ( isset( $this->cache[__METHOD__] ) ) {
278            return $this->cache[__METHOD__];
279        }
280        // @codeCoverageIgnoreEnd
281
282        $props = [];
283        $props['color'] = $matcherFactory->color();
284        $props['opacity'] = $matcherFactory->number();
285
286        $this->cache[__METHOD__] = $props;
287        return $props;
288    }
289
290    /**
291     * Data types for backgrounds
292     * @param MatcherFactory $matcherFactory Factory for Matchers
293     * @return array
294     */
295    protected function backgroundTypes( MatcherFactory $matcherFactory ) {
296        // @codeCoverageIgnoreStart
297        if ( isset( $this->cache[__METHOD__] ) ) {
298            return $this->cache[__METHOD__];
299        }
300        // @codeCoverageIgnoreEnd
301
302        $types = [];
303
304        $types['bgrepeat'] = new Alternative( [
305            new KeywordMatcher( [ 'repeat-x', 'repeat-y' ] ),
306            Quantifier::count( new KeywordMatcher( [ 'repeat', 'space', 'round', 'no-repeat' ] ), 1, 2 ),
307        ] );
308        $types['bgsize'] = new Alternative( [
309            Quantifier::count( new Alternative( [
310                $matcherFactory->lengthPercentage(),
311                new KeywordMatcher( 'auto' )
312            ] ), 1, 2 ),
313            new KeywordMatcher( [ 'cover', 'contain' ] )
314        ] );
315        $types['boxKeywords'] = [ 'border-box', 'padding-box', 'content-box' ];
316
317        $this->cache[__METHOD__] = $types;
318        return $types;
319    }
320
321    /**
322     * Keywords for the <box> production and its subsets
323     * @see https://www.w3.org/TR/2024/WD-css-box-4-20240804/#keywords
324     * @return array<string,string[]>
325     */
326    protected function boxEdgeKeywords() {
327        // @codeCoverageIgnoreStart
328        if ( isset( $this->cache[__METHOD__] ) ) {
329            return $this->cache[__METHOD__];
330        }
331        // @codeCoverageIgnoreEnd
332        $kws = [];
333        $kws['visual-box'] = [ 'content-box', 'padding-box', 'border-box' ];
334        $kws['layout-box'] = [ ...$kws['visual-box'], 'margin-box' ];
335        $kws['paint-box'] = [ ...$kws['visual-box'], 'fill-box', 'stroke-box' ];
336        $kws['coord-box'] = [ ...$kws['paint-box'], 'view-box' ];
337        $kws['box'] = [ 'content-box', 'padding-box', 'border-box', 'margin-box', 'fill-box',
338            'stroke-box', 'view-box' ];
339        return $kws;
340    }
341
342    /**
343     * Properties for CSS Backgrounds and Borders Module Level 3
344     * @see https://www.w3.org/TR/2024/CRD-css-backgrounds-3-20240311/
345     * @param MatcherFactory $matcherFactory Factory for Matchers
346     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
347     */
348    protected function cssBackgrounds3( MatcherFactory $matcherFactory ) {
349        // @codeCoverageIgnoreStart
350        if ( isset( $this->cache[__METHOD__] ) ) {
351            return $this->cache[__METHOD__];
352        }
353        // @codeCoverageIgnoreEnd
354
355        $props = [];
356
357        $types = $this->backgroundTypes( $matcherFactory );
358        $slash = new DelimMatcher( '/' );
359        $bgimage = new Alternative( [ new KeywordMatcher( 'none' ), $matcherFactory->image() ] );
360        $bgrepeat = $types['bgrepeat'];
361        $bgattach = new KeywordMatcher( [ 'scroll', 'fixed', 'local' ] );
362        $position = $matcherFactory->bgPosition();
363        $boxKeywords = $this->boxEdgeKeywords();
364        $visualBox = new KeywordMatcher( $boxKeywords['visual-box'] );
365        $bgsize = $types['bgsize'];
366        $bglayer = UnorderedGroup::someOf( [
367            $bgimage,
368            new Juxtaposition( [
369                $position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
370            ] ),
371            $bgrepeat,
372            $bgattach,
373            $visualBox,
374            $visualBox,
375        ] );
376        $finalBglayer = UnorderedGroup::someOf( [
377            $bgimage,
378            new Juxtaposition( [
379                $position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
380            ] ),
381            $bgrepeat,
382            $bgattach,
383            $visualBox,
384            $visualBox,
385            $matcherFactory->color(),
386        ] );
387
388        $props['background-color'] = $matcherFactory->color();
389        $props['background-image'] = Quantifier::hash( $bgimage );
390        $props['background-repeat'] = Quantifier::hash( $bgrepeat );
391        $props['background-attachment'] = Quantifier::hash( $bgattach );
392        $props['background-position'] = Quantifier::hash( $position );
393        $props['background-clip'] = Quantifier::hash( $visualBox );
394        $props['background-origin'] = $props['background-clip'];
395        $props['background-size'] = Quantifier::hash( $bgsize );
396        $props['background'] = new Juxtaposition(
397            [ Quantifier::hash( $bglayer, 0, INF ), $finalBglayer ], true
398        );
399
400        $lineStyle = new KeywordMatcher( [
401            'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'
402        ] );
403        $lineWidth = new Alternative( [
404            new KeywordMatcher( [ 'thin', 'medium', 'thick' ] ), $matcherFactory->length(),
405        ] );
406        $borderCombo = UnorderedGroup::someOf( [ $lineWidth, $lineStyle, $matcherFactory->color() ] );
407        $radius = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 2 );
408        $radius4 = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
409
410        $props['border-top-color'] = $matcherFactory->color();
411        $props['border-right-color'] = $matcherFactory->color();
412        $props['border-bottom-color'] = $matcherFactory->color();
413        $props['border-left-color'] = $matcherFactory->color();
414        // Because this property allows concatenation of color values, don't
415        // allow var(...) expressions here out of an abundance of caution.
416        $props['border-color'] = Quantifier::count( $matcherFactory->safeColor(), 1, 4 );
417        $props['border-top-style'] = $lineStyle;
418        $props['border-right-style'] = $lineStyle;
419        $props['border-bottom-style'] = $lineStyle;
420        $props['border-left-style'] = $lineStyle;
421        $props['border-style'] = Quantifier::count( $lineStyle, 1, 4 );
422        $props['border-top-width'] = $lineWidth;
423        $props['border-right-width'] = $lineWidth;
424        $props['border-bottom-width'] = $lineWidth;
425        $props['border-left-width'] = $lineWidth;
426        $props['border-width'] = Quantifier::count( $lineWidth, 1, 4 );
427        $props['border-top'] = $borderCombo;
428        $props['border-right'] = $borderCombo;
429        $props['border-bottom'] = $borderCombo;
430        $props['border-left'] = $borderCombo;
431        $props['border'] = $borderCombo;
432        $props['border-top-left-radius'] = $radius;
433        $props['border-top-right-radius'] = $radius;
434        $props['border-bottom-left-radius'] = $radius;
435        $props['border-bottom-right-radius'] = $radius;
436        $props['border-radius'] = new Juxtaposition( [
437            $radius4, Quantifier::optional( new Juxtaposition( [ $slash, $radius4 ] ) )
438        ] );
439        $props['border-image-source'] = new Alternative( [
440            new KeywordMatcher( 'none' ),
441            $matcherFactory->image()
442        ] );
443        $props['border-image-slice'] = UnorderedGroup::allOf( [
444            Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
445            Quantifier::optional( new KeywordMatcher( 'fill' ) ),
446        ] );
447        $props['border-image-width'] = Quantifier::count( new Alternative( [
448            $matcherFactory->length(),
449            $matcherFactory->percentage(),
450            $matcherFactory->number(),
451            new KeywordMatcher( 'auto' ),
452        ] ), 1, 4 );
453        $props['border-image-outset'] = Quantifier::count( new Alternative( [
454            $matcherFactory->length(),
455            $matcherFactory->number(),
456        ] ), 1, 4 );
457        $props['border-image-repeat'] = Quantifier::count( new KeywordMatcher( [
458            'stretch', 'repeat', 'round', 'space'
459        ] ), 1, 2 );
460        $props['border-image'] = UnorderedGroup::someOf( [
461            $props['border-image-source'],
462            new Juxtaposition( [
463                $props['border-image-slice'],
464                Quantifier::optional( new Alternative( [
465                    new Juxtaposition( [ $slash, $props['border-image-width'] ] ),
466                    new Juxtaposition( [
467                        $slash,
468                        Quantifier::optional( $props['border-image-width'] ),
469                        $slash,
470                        $props['border-image-outset']
471                    ] )
472                ] ) )
473            ] ),
474            $props['border-image-repeat']
475        ] );
476
477        $props['box-shadow'] = new Alternative( [
478            new KeywordMatcher( 'none' ),
479            Quantifier::hash( UnorderedGroup::allOf( [
480                Quantifier::optional( $matcherFactory->color() ),
481                Quantifier::count( $matcherFactory->length(), 2, 4 ),
482                Quantifier::optional( new KeywordMatcher( 'inset' ) ),
483            ] ) )
484        ] );
485
486        $this->cache[__METHOD__] = $props;
487        return $props;
488    }
489
490    /**
491     * Properties for CSS Images Module Level 3
492     * @see https://www.w3.org/TR/2023/CRD-css-images-3-20231218/
493     * @param MatcherFactory $matcherFactory Factory for Matchers
494     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
495     */
496    protected function cssImages3( MatcherFactory $matcherFactory ) {
497        // @codeCoverageIgnoreStart
498        if ( isset( $this->cache[__METHOD__] ) ) {
499            return $this->cache[__METHOD__];
500        }
501        // @codeCoverageIgnoreEnd
502
503        $props = [];
504
505        $props['object-fit'] = new KeywordMatcher( [ 'fill', 'contain', 'cover', 'none', 'scale-down' ] );
506        $props['object-position'] = $matcherFactory->position();
507
508        // Allow bare zero per legacy note at https://www.w3.org/TR/2024/WD-css-values-4-20240312/#angles
509        $a = new Alternative( [
510            $matcherFactory->zero(),
511            $matcherFactory->angle(),
512        ] );
513        $props['image-orientation'] = new Alternative( [
514            new KeywordMatcher( [ 'from-image', 'none' ] ),
515            UnorderedGroup::someOf( [
516                $a,
517                new KeywordMatcher( [ 'flip' ] ),
518            ] ),
519        ] );
520
521        $props['image-rendering'] = new KeywordMatcher( [
522            'auto', 'smooth', 'high-quality', 'crisp-edges', 'pixelated'
523        ] );
524
525        $this->cache[__METHOD__] = $props;
526        return $props;
527    }
528
529    /**
530     * Properties for CSS Fonts Module Level 3
531     * @see https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/
532     * @param MatcherFactory $matcherFactory Factory for Matchers
533     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
534     */
535    protected function cssFonts3( MatcherFactory $matcherFactory ) {
536        // @codeCoverageIgnoreStart
537        if ( isset( $this->cache[__METHOD__] ) ) {
538            return $this->cache[__METHOD__];
539        }
540        // @codeCoverageIgnoreEnd
541
542        $css2 = $this->css2( $matcherFactory );
543        $props = [];
544
545        $matchData = FontFaceAtRuleSanitizer::fontMatchData( $matcherFactory );
546
547        // Note: <generic-family> is syntactically a subset of <family-name>,
548        // so no point in separately listing it.
549        $props['font-family'] = Quantifier::hash( $matchData['familyName'] );
550        $props['font-weight'] = new Alternative( [
551            new KeywordMatcher( [ 'normal', 'bold', 'bolder', 'lighter' ] ),
552            $matchData['numWeight'],
553        ] );
554        $props['font-stretch'] = $matchData['font-stretch'];
555        $props['font-style'] = $matchData['font-style'];
556        $props['font-size'] = new Alternative( [
557            new KeywordMatcher( [
558                'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'larger', 'smaller'
559            ] ),
560            $matcherFactory->lengthPercentage(),
561        ] );
562        $props['font-size-adjust'] = new Alternative( [
563            new KeywordMatcher( 'none' ), $matcherFactory->number()
564        ] );
565        $props['font'] = new Alternative( [
566            new Juxtaposition( [
567                Quantifier::optional( UnorderedGroup::someOf( [
568                    $props['font-style'],
569                    new KeywordMatcher( [ 'normal', 'small-caps' ] ),
570                    $props['font-weight'],
571                    $props['font-stretch'],
572                ] ) ),
573                $props['font-size'],
574                Quantifier::optional( new Juxtaposition( [
575                    new DelimMatcher( '/' ),
576                    $css2['line-height'],
577                ] ) ),
578                $props['font-family'],
579            ] ),
580            new KeywordMatcher( [ 'caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar' ] )
581        ] );
582        $props['font-synthesis'] = new Alternative( [
583            new KeywordMatcher( 'none' ),
584            UnorderedGroup::someOf( [
585                new KeywordMatcher( 'weight' ),
586                new KeywordMatcher( 'style' ),
587            ] )
588        ] );
589        $props['font-kerning'] = new KeywordMatcher( [ 'auto', 'normal', 'none' ] );
590        $props['font-variant-ligatures'] = new Alternative( [
591            new KeywordMatcher( [ 'normal', 'none' ] ),
592            UnorderedGroup::someOf( $matchData['ligatures'] )
593        ] );
594        $props['font-variant-position'] = new KeywordMatcher(
595            array_merge( [ 'normal' ], $matchData['positionKeywords'] )
596        );
597        $props['font-variant-caps'] = new KeywordMatcher(
598            array_merge( [ 'normal' ], $matchData['capsKeywords'] )
599        );
600        $props['font-variant-numeric'] = new Alternative( [
601            new KeywordMatcher( 'normal' ),
602            UnorderedGroup::someOf( $matchData['numeric'] )
603        ] );
604        $props['font-variant-east-asian'] = new Alternative( [
605            new KeywordMatcher( 'normal' ),
606            UnorderedGroup::someOf( $matchData['eastAsian'] )
607        ] );
608        $props['font-variant'] = $matchData['font-variant'];
609        $props['font-feature-settings'] = $matchData['font-feature-settings'];
610
611        $this->cache[__METHOD__] = $props;
612        return $props;
613    }
614
615    /**
616     * Properties for CSS Multi-column Layout Module
617     * @see https://www.w3.org/TR/2024/CR-css-multicol-1-20240516/
618     * @param MatcherFactory $matcherFactory Factory for Matchers
619     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
620     */
621    protected function cssMulticol( MatcherFactory $matcherFactory ) {
622        // @codeCoverageIgnoreStart
623        if ( isset( $this->cache[__METHOD__] ) ) {
624            return $this->cache[__METHOD__];
625        }
626        // @codeCoverageIgnoreEnd
627
628        $borders = $this->cssBackgrounds3( $matcherFactory );
629        $props = [];
630
631        $auto = new KeywordMatcher( 'auto' );
632
633        $props['column-width'] = new Alternative( array_merge(
634            [ $matcherFactory->length(), $auto ],
635            // Additional values from https://www.w3.org/TR/2021/WD-css-sizing-3-20211217/
636            // Note! This adds support for a now invalid `column-width: min-width`.
637            // Should probably be removed once new CSS specifications are released.
638            $this->getSizingAdditions3( $matcherFactory )
639        ) );
640        $props['column-count'] = new Alternative( [ $matcherFactory->integer(), $auto ] );
641        $props['columns'] = UnorderedGroup::someOf( [ $props['column-width'], $props['column-count'] ] );
642        // Copy these from similar items in the Border module
643        $props['column-rule-color'] = $borders['border-right-color'];
644        $props['column-rule-style'] = $borders['border-right-style'];
645        $props['column-rule-width'] = $borders['border-right-width'];
646        $props['column-rule'] = $borders['border-right'];
647        $props['column-span'] = new KeywordMatcher( [ 'none', 'all' ] );
648        $props['column-fill'] = new KeywordMatcher( [ 'auto', 'balance', 'balance-all' ] );
649
650        // Copy these from cssBreak3(), the duplication is allowed as long as
651        // they're the identical Matcher object.
652        $breaks = $this->cssBreak3( $matcherFactory );
653        $props['break-before'] = $breaks['break-before'];
654        $props['break-after'] = $breaks['break-after'];
655        $props['break-inside'] = $breaks['break-inside'];
656
657        // And one from cssAlign3
658        $props['column-gap'] = $this->cssAlign3( $matcherFactory )['column-gap'];
659
660        $this->cache[__METHOD__] = $props;
661        return $props;
662    }
663
664    /**
665     * Properties for CSS Overflow Module Level 3
666     * @see https://www.w3.org/TR/2023/WD-css-overflow-3-20230329/
667     * @param MatcherFactory $matcherFactory Factory for Matchers
668     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
669     */
670    protected function cssOverflow3( MatcherFactory $matcherFactory ) {
671        // @codeCoverageIgnoreStart
672        if ( isset( $this->cache[__METHOD__] ) ) {
673            return $this->cache[__METHOD__];
674        }
675        // @codeCoverageIgnoreEnd
676
677        $props = [];
678
679        $overflow = new KeywordMatcher( [ 'visible', 'hidden', 'clip', 'scroll', 'auto' ] );
680        $props['overflow'] = Quantifier::count( $overflow, 1, 2 );
681        $props['overflow-x'] = $overflow;
682        $props['overflow-y'] = $overflow;
683        $props['overflow-inline'] = $overflow;
684        $props['overflow-block'] = $overflow;
685        $props['overflow-clip-margin'] = UnorderedGroup::someOf( [
686            new KeywordMatcher( [ 'content-box', 'padding-box', 'border-box' ] ),
687            $matcherFactory->length()
688        ] );
689
690        $props['text-overflow'] = new KeywordMatcher( [ 'clip', 'ellipsis' ] );
691
692        $props['scroll-behavior'] = new KeywordMatcher( [ 'auto', 'smooth' ] );
693        $props['scrollbar-gutter'] = new Alternative( [
694            new KeywordMatcher( 'auto' ),
695            UnorderedGroup::allOf( [
696                new KeywordMatcher( 'stable' ),
697                Quantifier::optional( new KeywordMatcher( 'both-edges' ) )
698            ] )
699        ] );
700
701        $this->cache[__METHOD__] = $props;
702        return $props;
703    }
704
705    /**
706     * Properties for CSS Overflow Module Level 4
707     * @see https://www.w3.org/TR/2023/WD-css-overflow-4-20230321/
708     * @param MatcherFactory $matcherFactory Factory for Matchers
709     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
710     */
711    protected function cssOverflow4( MatcherFactory $matcherFactory ) {
712        // @codeCoverageIgnoreStart
713        if ( isset( $this->cache[__METHOD__] ) ) {
714            return $this->cache[__METHOD__];
715        }
716        // @codeCoverageIgnoreEnd
717        $props = $this->cssOverflow3( $matcherFactory );
718        $props['-webkit-line-clamp'] = new Alternative( [
719            new KeywordMatcher( 'none' ),
720            $matcherFactory->integer()
721        ] );
722        $props['block-ellipsis'] = new Alternative( [
723            new KeywordMatcher( [ 'none', 'auto' ] ),
724            $matcherFactory->string()
725        ] );
726        $props['continue'] = new KeywordMatcher( [ 'auto', 'discard' ] );
727        $props['line-clamp'] = new Alternative( [
728            new KeywordMatcher( 'none' ),
729            new Juxtaposition( [
730                $matcherFactory->integer(),
731                Quantifier::optional( $props['block-ellipsis'] ),
732            ] ),
733        ] );
734        $props['max-lines'] = new Alternative( [
735            new KeywordMatcher( 'none' ), $matcherFactory->integer()
736        ] );
737        $clipMargin = $props['overflow-clip-margin'];
738        $props['overflow-clip-margin-block'] = $clipMargin;
739        $props['overflow-clip-margin-block-end'] = $clipMargin;
740        $props['overflow-clip-margin-block-start'] = $clipMargin;
741        $props['overflow-clip-margin-bottom'] = $clipMargin;
742        $props['overflow-clip-margin-inline'] = $clipMargin;
743        $props['overflow-clip-margin-inline-end'] = $clipMargin;
744        $props['overflow-clip-margin-inline-start'] = $clipMargin;
745        $props['overflow-clip-margin-inline-left'] = $clipMargin;
746        $props['overflow-clip-margin-left'] = $clipMargin;
747        $props['overflow-clip-margin-right'] = $clipMargin;
748        $props['overflow-clip-margin-top'] = $clipMargin;
749        $props['text-overflow'] = Quantifier::count(
750            new Alternative( [
751                new KeywordMatcher( [ 'clip', 'ellipsis' ] ),
752                $matcherFactory->string(),
753                new KeywordMatcher( 'fade' ),
754                new FunctionMatcher(
755                    'fade',
756                    new Alternative( [ $matcherFactory->length(), $matcherFactory->percentage() ] )
757                )
758            ] ),
759            1, 2
760        );
761
762        $this->cache[__METHOD__] = $props;
763        return $props;
764    }
765
766    /**
767     * Properties for CSS Basic User Interface Module Level 4
768     * @see https://www.w3.org/TR/2018/REC-css-ui-3-20180621/
769     * @see https://www.w3.org/TR/2021/WD-css-ui-4-20210316/
770     * @param MatcherFactory $matcherFactory Factory for Matchers
771     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
772     */
773    protected function cssUI4( MatcherFactory $matcherFactory ) {
774        // @codeCoverageIgnoreStart
775        if ( isset( $this->cache[__METHOD__] ) ) {
776            return $this->cache[__METHOD__];
777        }
778        // @codeCoverageIgnoreEnd
779
780        $border = $this->cssBackgrounds3( $matcherFactory );
781        $props = [];
782
783        // Copy these from similar border properties
784        $props['outline-width'] = $border['border-top-width'];
785        $props['outline-style'] = new Alternative( [
786            new KeywordMatcher( 'auto' ), $border['border-top-style']
787        ] );
788        $props['outline-color'] = new Alternative( [
789            new KeywordMatcher( 'invert' ), $matcherFactory->color()
790        ] );
791        $props['outline'] = UnorderedGroup::someOf( [
792            $props['outline-width'], $props['outline-style'], $props['outline-color']
793        ] );
794        $props['outline-offset'] = $matcherFactory->length();
795        $props['resize'] = new KeywordMatcher( [
796            'none', 'both', 'horizontal', 'vertical', 'block', 'inline',
797        ] );
798        $props['cursor'] = new Juxtaposition( [
799            Quantifier::star( new Juxtaposition( [
800                $matcherFactory->image(),
801                Quantifier::optional( new Juxtaposition( [
802                    $matcherFactory->number(), $matcherFactory->number()
803                ] ) ),
804                $matcherFactory->comma(),
805            ] ) ),
806            new KeywordMatcher( [
807                'auto', 'default', 'none', 'context-menu', 'help', 'pointer', 'progress', 'wait', 'cell',
808                'crosshair', 'text', 'vertical-text', 'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab',
809                'grabbing', 'e-resize', 'n-resize', 'ne-resize', 'nw-resize', 's-resize', 'se-resize',
810                'sw-resize', 'w-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'col-resize',
811                'row-resize', 'all-scroll', 'zoom-in', 'zoom-out',
812            ] ),
813        ] );
814        $props['caret-color'] = new Alternative( [
815            new KeywordMatcher( 'auto' ), $matcherFactory->color()
816        ] );
817        $props['caret-shape'] = new KeywordMatcher( [ 'auto', 'bar', 'block', 'underscore' ] );
818        $props['caret'] = UnorderedGroup::someOf( [ $props['caret-color'], $props['caret-shape'] ] );
819        $props['nav-up'] = new Alternative( [
820            new KeywordMatcher( 'auto' ),
821            new Juxtaposition( [
822                $matcherFactory->cssID(),
823                Quantifier::optional( new Alternative( [
824                    new KeywordMatcher( [ 'current', 'root' ] ),
825                    $matcherFactory->string(),
826                ] ) )
827            ] )
828        ] );
829        $props['nav-right'] = $props['nav-up'];
830        $props['nav-down'] = $props['nav-up'];
831        $props['nav-left'] = $props['nav-up'];
832
833        $props['user-select'] = new KeywordMatcher( [ 'auto', 'text', 'none', 'contain', 'all' ] );
834        // Seems potentially useful enough to let the prefixed versions work.
835        $props['-moz-user-select'] = $props['user-select'];
836        $props['-ms-user-select'] = $props['user-select'];
837        $props['-webkit-user-select'] = $props['user-select'];
838
839        $props['accent-color'] = new Alternative( [
840            new KeywordMatcher( 'auto' ),
841            $matcherFactory->color(),
842        ] );
843
844        $props['appearance'] = new KeywordMatcher( [
845            'none', 'auto', 'button', 'textfield', 'menulist-button',
846        ] );
847
848        $this->cache[__METHOD__] = $props;
849        return $props;
850    }
851
852    /**
853     * Properties for CSS Compositing and Blending Level 1
854     * @see https://www.w3.org/TR/2024/CRD-compositing-1-20240321/
855     * @param MatcherFactory $matcherFactory Factory for Matchers
856     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
857     */
858    protected function cssCompositing1( MatcherFactory $matcherFactory ) {
859        // @codeCoverageIgnoreStart
860        if ( isset( $this->cache[__METHOD__] ) ) {
861            return $this->cache[__METHOD__];
862        }
863        // @codeCoverageIgnoreEnd
864
865        $props = [];
866
867        $props['mix-blend-mode'] = new KeywordMatcher( [
868            'normal', 'darken', 'multiply', 'color-burn', 'lighten', 'screen', 'color-dodge', 'overlay',
869            'soft-light', 'hard-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'
870        ] );
871        $props['isolation'] = new KeywordMatcher( [ 'auto', 'isolate' ] );
872
873        // The linked spec incorrectly has this without the hash, despite the
874        // textual description and examples showing it as such. The draft has it fixed.
875        $props['background-blend-mode'] = Quantifier::hash( $props['mix-blend-mode'] );
876
877        $this->cache[__METHOD__] = $props;
878        return $props;
879    }
880
881    /**
882     * Properties for CSS Writing Modes Level 4
883     * @see https://www.w3.org/TR/2019/CR-css-writing-modes-4-20190730/
884     * @param MatcherFactory $matcherFactory Factory for Matchers
885     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
886     */
887    protected function cssWritingModes4( MatcherFactory $matcherFactory ) {
888        // @codeCoverageIgnoreStart
889        if ( isset( $this->cache[__METHOD__] ) ) {
890            return $this->cache[__METHOD__];
891        }
892        // @codeCoverageIgnoreEnd
893
894        $props = [];
895
896        $props['direction'] = new KeywordMatcher( [ 'ltr', 'rtl' ] );
897        $props['unicode-bidi'] = new KeywordMatcher( [
898            'normal', 'embed', 'isolate', 'bidi-override', 'isolate-override', 'plaintext'
899        ] );
900        $props['writing-mode'] = new KeywordMatcher( [
901            'horizontal-tb', 'vertical-rl', 'vertical-lr', 'sideways-rl', 'sideways-lr',
902        ] );
903        $props['text-orientation'] = new KeywordMatcher( [ 'mixed', 'upright', 'sideways' ] );
904        $props['text-combine-upright'] = new Alternative( [
905            new KeywordMatcher( [ 'none', 'all' ] ),
906            new Juxtaposition( [
907                new KeywordMatcher( [ 'digits' ] ),
908                Quantifier::optional( $matcherFactory->integer() ),
909            ] ),
910        ] );
911
912        $this->cache[__METHOD__] = $props;
913        return $props;
914    }
915
916    /**
917     * Properties for CSS Transitions
918     * @see https://www.w3.org/TR/2018/WD-css-transitions-1-20181011/
919     * @param MatcherFactory $matcherFactory Factory for Matchers
920     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
921     */
922    protected function cssTransitions( MatcherFactory $matcherFactory ) {
923        // @codeCoverageIgnoreStart
924        if ( isset( $this->cache[__METHOD__] ) ) {
925            return $this->cache[__METHOD__];
926        }
927        // @codeCoverageIgnoreEnd
928
929        $props = [];
930        $property = new Alternative( [
931            new KeywordMatcher( [ 'all' ] ),
932            $matcherFactory->customIdent( [ 'none' ] ),
933        ] );
934        $none = new KeywordMatcher( 'none' );
935        $singleEasingFunction = $matcherFactory->cssSingleEasingFunction();
936
937        $props['transition-property'] = new Alternative( [
938            $none, Quantifier::hash( $property )
939        ] );
940        $props['transition-duration'] = Quantifier::hash( $matcherFactory->time() );
941        $props['transition-timing-function'] = Quantifier::hash( $singleEasingFunction );
942        $props['transition-delay'] = Quantifier::hash( $matcherFactory->time() );
943        $props['transition'] = Quantifier::hash( UnorderedGroup::someOf( [
944            new Alternative( [ $none, $property ] ),
945            $matcherFactory->time(),
946            $singleEasingFunction,
947            $matcherFactory->time(),
948        ] ) );
949
950        $this->cache[__METHOD__] = $props;
951        return $props;
952    }
953
954    /**
955     * Properties for CSS Animations
956     * @see https://www.w3.org/TR/2023/WD-css-animations-1-20230302/
957     * @param MatcherFactory $matcherFactory Factory for Matchers
958     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
959     */
960    protected function cssAnimations( MatcherFactory $matcherFactory ) {
961        // @codeCoverageIgnoreStart
962        if ( isset( $this->cache[__METHOD__] ) ) {
963            return $this->cache[__METHOD__];
964        }
965        // @codeCoverageIgnoreEnd
966
967        $props = [];
968        $name = new Alternative( [
969            new KeywordMatcher( [ 'none' ] ),
970            $matcherFactory->customIdent( [ 'none' ] ),
971            $matcherFactory->string(),
972        ] );
973        $singleEasingFunction = $matcherFactory->cssSingleEasingFunction();
974        $count = new Alternative( [
975            new KeywordMatcher( 'infinite' ),
976            $matcherFactory->number()
977        ] );
978        $direction = new KeywordMatcher( [ 'normal', 'reverse', 'alternate', 'alternate-reverse' ] );
979        $playState = new KeywordMatcher( [ 'running', 'paused' ] );
980        $fillMode = new KeywordMatcher( [ 'none', 'forwards', 'backwards', 'both' ] );
981
982        $props['animation-name'] = Quantifier::hash( $name );
983        $props['animation-duration'] = Quantifier::hash( $matcherFactory->time() );
984        $props['animation-timing-function'] = Quantifier::hash( $singleEasingFunction );
985        $props['animation-iteration-count'] = Quantifier::hash( $count );
986        $props['animation-direction'] = Quantifier::hash( $direction );
987        $props['animation-play-state'] = Quantifier::hash( $playState );
988        $props['animation-delay'] = Quantifier::hash( $matcherFactory->time() );
989        $props['animation-fill-mode'] = Quantifier::hash( $fillMode );
990        $props['animation'] = Quantifier::hash( UnorderedGroup::someOf( [
991            $matcherFactory->time(),
992            $singleEasingFunction,
993            $matcherFactory->time(),
994            $count,
995            $direction,
996            $fillMode,
997            $playState,
998            $name,
999        ] ) );
1000
1001        $this->cache[__METHOD__] = $props;
1002        return $props;
1003    }
1004
1005    /**
1006     * Properties for CSS Flexible Box Layout Module Level 1
1007     * @see https://www.w3.org/TR/2018/CR-css-flexbox-1-20181119/
1008     * @param MatcherFactory $matcherFactory Factory for Matchers
1009     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1010     */
1011    protected function cssFlexbox3( MatcherFactory $matcherFactory ) {
1012        // @codeCoverageIgnoreStart
1013        if ( isset( $this->cache[__METHOD__] ) ) {
1014            return $this->cache[__METHOD__];
1015        }
1016        // @codeCoverageIgnoreEnd
1017
1018        $props = [];
1019        $props['flex-direction'] = new KeywordMatcher( [
1020            'row', 'row-reverse', 'column', 'column-reverse'
1021        ] );
1022        $props['flex-wrap'] = new KeywordMatcher( [ 'nowrap', 'wrap', 'wrap-reverse' ] );
1023        $props['flex-flow'] = UnorderedGroup::someOf( [ $props['flex-direction'], $props['flex-wrap'] ] );
1024        $props['flex-grow'] = $matcherFactory->number();
1025        $props['flex-shrink'] = $matcherFactory->number();
1026        $props['flex-basis'] = new Alternative( [
1027            new KeywordMatcher( [ 'content' ] ),
1028            $this->cssSizing4( $matcherFactory )['width']
1029        ] );
1030        $props['flex'] = new Alternative( [
1031            new KeywordMatcher( 'none' ),
1032            UnorderedGroup::someOf( [
1033                new Juxtaposition( [ $props['flex-grow'], Quantifier::optional( $props['flex-shrink'] ) ] ),
1034                $props['flex-basis'],
1035            ] )
1036        ] );
1037
1038        // The alignment module supersedes the ones in flexbox. Copying is ok as long as
1039        // it's the identical object.
1040        $align = $this->cssAlign3( $matcherFactory );
1041        $props['justify-content'] = $align['justify-content'];
1042        $props['align-items'] = $align['align-items'];
1043        $props['align-self'] = $align['align-self'];
1044        $props['align-content'] = $align['align-content'];
1045
1046        // 'order' was copied into display-3 in CR 2023-03-30
1047        // Removed from flexbox in the ED as of 2025-03-10, it can be removed
1048        // here once we update our flexbox version.
1049        $props['order'] = $this->cssDisplay3( $matcherFactory )['order'];
1050
1051        $this->cache[__METHOD__] = $props;
1052        return $props;
1053    }
1054
1055    /**
1056     * Get a matcher for any transform function for a given level of the
1057     * Transforms module.
1058     *
1059     * @see https://www.w3.org/TR/2019/CR-css-transforms-1-20190214/
1060     * @see https://www.w3.org/TR/2021/WD-css-transforms-2-20211109/
1061     *
1062     * @param MatcherFactory $matcherFactory
1063     * @param int $level
1064     * @return Alternative
1065     */
1066    protected function transformFunc( MatcherFactory $matcherFactory, int $level ) {
1067        $a = $matcherFactory->angle();
1068        $az = new Alternative( [
1069            $matcherFactory->zero(),
1070            $a,
1071        ] );
1072        $n = $matcherFactory->number();
1073        $l = $matcherFactory->length();
1074        $lp = $matcherFactory->lengthPercentage();
1075        $np = new Alternative( [ $n, $matcherFactory->percentage() ] );
1076
1077        $level1 = [
1078            'matrix' => Quantifier::hash( $n, 6, 6 ),
1079            'translate' => Quantifier::hash( $lp, 1, 2 ),
1080            'translateX' => $lp,
1081            'translateY' => $lp,
1082            'scale' => Quantifier::hash( $n, 1, 2 ),
1083            'scaleX' => $n,
1084            'scaleY' => $n,
1085            'rotate' => $az,
1086            'skew' => Quantifier::hash( $az, 1, 2 ),
1087            'skewX' => $az,
1088            'skewY' => $az,
1089        ];
1090
1091        $level2 = [
1092            'scale' => Quantifier::hash( $np, 1, 2 ),
1093            'scaleX' => $np,
1094            'scaleY' => $np,
1095            'matrix3d' => Quantifier::hash( $n, 16, 16 ),
1096            'translate3d' => new Juxtaposition( [ $lp, $lp, $l ], true ),
1097            'translateZ' => $l,
1098            'scale3d' => Quantifier::hash( $np, 3, 3 ),
1099            'scaleZ' => $np,
1100            'rotate3d' => new Juxtaposition( [ $n, $n, $n, $az ], true ),
1101            'rotateX' => $az,
1102            'rotateY' => $az,
1103            'rotateZ' => $az,
1104            'perspective' => new Alternative( [ $l, new KeywordMatcher( 'none' ) ] ),
1105        ];
1106
1107        if ( $level === 1 ) {
1108            $funcs = $level1;
1109        } else {
1110            $funcs = $level2 + $level1;
1111        }
1112        $funcMatchers = [];
1113        foreach ( $funcs as $name => $contents ) {
1114            $funcMatchers[] = new FunctionMatcher( $name, $contents );
1115        }
1116        return new Alternative( $funcMatchers );
1117    }
1118
1119    /**
1120     * Properties for CSS Transforms Module Level 1
1121     *
1122     * @see https://www.w3.org/TR/2019/CR-css-transforms-1-20190214/
1123     * @param MatcherFactory $matcherFactory Factory for Matchers
1124     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1125     */
1126    protected function cssTransforms1( MatcherFactory $matcherFactory ) {
1127        // @codeCoverageIgnoreStart
1128        if ( isset( $this->cache[__METHOD__] ) ) {
1129            return $this->cache[__METHOD__];
1130        }
1131        // @codeCoverageIgnoreEnd
1132
1133        $props = [];
1134        $l = $matcherFactory->length();
1135        $ol = Quantifier::optional( $l );
1136        $lp = $matcherFactory->lengthPercentage();
1137        $center = new KeywordMatcher( 'center' );
1138        $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
1139        $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
1140
1141        $props['transform'] = new Alternative( [
1142            new KeywordMatcher( 'none' ),
1143            Quantifier::plus( $this->transformFunc( $matcherFactory, 1 ) )
1144        ] );
1145
1146        $props['transform-origin'] = new Alternative( [
1147            new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
1148            new Juxtaposition( [
1149                new Alternative( [ $center, $leftRight, $lp ] ),
1150                new Alternative( [ $center, $topBottom, $lp ] ),
1151                $ol
1152            ] ),
1153            new Juxtaposition( [
1154                UnorderedGroup::allOf( [
1155                    new Alternative( [ $center, $leftRight ] ),
1156                    new Alternative( [ $center, $topBottom ] ),
1157                ] ),
1158                $ol,
1159            ] )
1160        ] );
1161        $props['transform-box'] = new KeywordMatcher( [
1162            'content-box', 'border-box', 'fill-box', 'stroke-box', 'view-box'
1163        ] );
1164
1165        $this->cache[__METHOD__] = $props;
1166        return $props;
1167    }
1168
1169    /**
1170     * Properties for CSS Transforms Module Levels 1 and 2
1171     *
1172     * @see https://www.w3.org/TR/2019/CR-css-transforms-1-20190214/
1173     * @see https://www.w3.org/TR/2021/WD-css-transforms-2-20211109/
1174     * @param MatcherFactory $matcherFactory Factory for Matchers
1175     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1176     */
1177    protected function cssTransforms2( MatcherFactory $matcherFactory ) {
1178        if ( !isset( $this->cache[__METHOD__] ) ) {
1179            $none = new KeywordMatcher( 'none' );
1180
1181            $this->cache[__METHOD__] = [
1182                'transform' => new Alternative( [
1183                    new KeywordMatcher( 'none' ),
1184                    Quantifier::plus( $this->transformFunc( $matcherFactory, 2 ) )
1185                ] ),
1186                'backface-visibility' => new KeywordMatcher( [ 'visible', 'hidden' ] ),
1187                'perspective' => new Alternative( [
1188                    $none,
1189                    $matcherFactory->length()
1190                ] ),
1191                'perspective-origin' => $matcherFactory->position(),
1192                'rotate' => new Alternative( [
1193                    $none,
1194                    $matcherFactory->angle(),
1195                    UnorderedGroup::allOf( [
1196                        new Alternative( [
1197                            new KeywordMatcher( [ 'x', 'y', 'z' ] ),
1198                            Quantifier::count( $matcherFactory->number(), 3, 3 )
1199                        ] ),
1200                        $matcherFactory->angle()
1201                    ] )
1202                ] ),
1203                'scale' => new Alternative( [
1204                    $none,
1205                    Quantifier::count(
1206                        new Alternative( [
1207                            $matcherFactory->number(),
1208                            $matcherFactory->percentage()
1209                        ] ),
1210                        1, 3
1211                    )
1212                ] ),
1213                'transform-style' => new KeywordMatcher( [ 'flat', 'preserve-3d' ] ),
1214                'translate' => new Alternative( [
1215                    $none,
1216                    new Juxtaposition( [
1217                        $matcherFactory->lengthPercentage(),
1218                        Quantifier::optional(
1219                            new Juxtaposition( [
1220                                $matcherFactory->lengthPercentage(),
1221                                Quantifier::optional( $matcherFactory->length() )
1222                            ] )
1223                        )
1224                    ] )
1225                ] )
1226            ] + $this->cssTransforms1( $matcherFactory );
1227        }
1228        return $this->cache[__METHOD__];
1229    }
1230
1231    /**
1232     * Properties for CSS Text Module Level 3
1233     * @see https://www.w3.org/TR/2024/CRD-css-text-3-20240930/
1234     * @param MatcherFactory $matcherFactory Factory for Matchers
1235     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1236     */
1237    protected function cssText3( MatcherFactory $matcherFactory ) {
1238        // @codeCoverageIgnoreStart
1239        if ( isset( $this->cache[__METHOD__] ) ) {
1240            return $this->cache[__METHOD__];
1241        }
1242        // @codeCoverageIgnoreEnd
1243
1244        $props = [];
1245
1246        $props['text-transform'] = new Alternative( [
1247            new KeywordMatcher( [ 'none' ] ),
1248            UnorderedGroup::someOf( [
1249                new KeywordMatcher( [ 'capitalize', 'uppercase', 'lowercase', 'full-width' ] ),
1250                new KeywordMatcher( [ 'full-width' ] ),
1251                new KeywordMatcher( [ 'full-size-kana' ] ),
1252            ] ),
1253        ] );
1254        $props['white-space'] = new KeywordMatcher( [
1255            'normal', 'pre', 'nowrap', 'pre-wrap', 'break-spaces', 'pre-line'
1256        ] );
1257        $props['tab-size'] = new Alternative( [ $matcherFactory->number(), $matcherFactory->length() ] );
1258        $props['line-break'] = new KeywordMatcher( [ 'auto', 'loose', 'normal', 'strict', 'anywhere' ] );
1259        $props['word-break'] = new KeywordMatcher( [ 'normal', 'keep-all', 'break-all', 'break-word' ] );
1260        $props['hyphens'] = new KeywordMatcher( [ 'none', 'manual', 'auto' ] );
1261        $props['word-wrap'] = new KeywordMatcher( [ 'normal', 'break-word', 'anywhere' ] );
1262        $props['overflow-wrap'] = $props['word-wrap'];
1263        $props['text-align'] = new KeywordMatcher( [
1264            'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent', 'justify-all'
1265        ] );
1266        $props['text-align-all'] = new KeywordMatcher( [
1267            'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent'
1268        ] );
1269        $props['text-align-last'] = new KeywordMatcher( [
1270            'auto', 'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent'
1271        ] );
1272        $props['text-justify'] = new KeywordMatcher( [
1273            'auto', 'none', 'inter-word', 'inter-character'
1274        ] );
1275        $props['word-spacing'] = new Alternative( [
1276            new KeywordMatcher( 'normal' ),
1277            $matcherFactory->length()
1278        ] );
1279        $props['letter-spacing'] = new Alternative( [
1280            new KeywordMatcher( 'normal' ),
1281            $matcherFactory->length()
1282        ] );
1283        $props['text-indent'] = UnorderedGroup::allOf( [
1284            $matcherFactory->lengthPercentage(),
1285            Quantifier::optional( new KeywordMatcher( 'hanging' ) ),
1286            Quantifier::optional( new KeywordMatcher( 'each-line' ) ),
1287        ] );
1288        $props['hanging-punctuation'] = new Alternative( [
1289            new KeywordMatcher( 'none' ),
1290            UnorderedGroup::someOf( [
1291                new KeywordMatcher( 'first' ),
1292                new KeywordMatcher( [ 'force-end', 'allow-end' ] ),
1293                new KeywordMatcher( 'last' ),
1294            ] )
1295        ] );
1296
1297        $this->cache[__METHOD__] = $props;
1298        return $props;
1299    }
1300
1301    /**
1302     * Properties for CSS Text Decoration Module Level 3
1303     * @see https://www.w3.org/TR/2022/CRD-css-text-decor-3-20220505/
1304     * @param MatcherFactory $matcherFactory Factory for Matchers
1305     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1306     */
1307    protected function cssTextDecor3( MatcherFactory $matcherFactory ) {
1308        // @codeCoverageIgnoreStart
1309        if ( isset( $this->cache[__METHOD__] ) ) {
1310            return $this->cache[__METHOD__];
1311        }
1312        // @codeCoverageIgnoreEnd
1313
1314        $props = [];
1315
1316        $props['text-decoration-line'] = new Alternative( [
1317            new KeywordMatcher( 'none' ),
1318            UnorderedGroup::someOf( [
1319                new KeywordMatcher( 'underline' ),
1320                new KeywordMatcher( 'overline' ),
1321                new KeywordMatcher( 'line-through' ),
1322                // new KeywordMatcher( 'blink' ), // NOOO!!!
1323            ] )
1324        ] );
1325        $props['text-decoration-color'] = $matcherFactory->color();
1326        $props['text-decoration-style'] = new KeywordMatcher( [
1327            'solid', 'double', 'dotted', 'dashed', 'wavy'
1328        ] );
1329        $props['text-decoration'] = UnorderedGroup::someOf( [
1330            $props['text-decoration-line'],
1331            $props['text-decoration-style'],
1332            $props['text-decoration-color'],
1333        ] );
1334        $props['text-underline-position'] = new Alternative( [
1335            new KeywordMatcher( 'auto' ),
1336            UnorderedGroup::someOf( [
1337                new KeywordMatcher( 'under' ),
1338                new KeywordMatcher( [ 'left', 'right' ] ),
1339            ] )
1340        ] );
1341        $props['text-emphasis-style'] = new Alternative( [
1342            new KeywordMatcher( 'none' ),
1343            UnorderedGroup::someOf( [
1344                new KeywordMatcher( [ 'filled', 'open' ] ),
1345                new KeywordMatcher( [ 'dot', 'circle', 'double-circle', 'triangle', 'sesame' ] )
1346            ] ),
1347            $matcherFactory->string(),
1348        ] );
1349        $props['text-emphasis-color'] = $matcherFactory->color();
1350        $props['text-emphasis'] = UnorderedGroup::someOf( [
1351            $props['text-emphasis-style'],
1352            $props['text-emphasis-color'],
1353        ] );
1354        $props['text-emphasis-position'] = UnorderedGroup::allOf( [
1355            new KeywordMatcher( [ 'over', 'under' ] ),
1356            Quantifier::optional( new KeywordMatcher( [ 'right', 'left' ] ) ),
1357        ] );
1358        $props['text-shadow'] = new Alternative( [
1359            new KeywordMatcher( 'none' ),
1360            Quantifier::hash( UnorderedGroup::allOf( [
1361                Quantifier::count( $matcherFactory->length(), 2, 3 ),
1362                Quantifier::optional( $matcherFactory->color() ),
1363            ] ) )
1364        ] );
1365
1366        $this->cache[__METHOD__] = $props;
1367        return $props;
1368    }
1369
1370    /**
1371     * Properties for CSS Box Alignment Module Level 3
1372     * @see https://www.w3.org/TR/2025/WD-css-align-3-20250311/
1373     * @param MatcherFactory $matcherFactory Factory for Matchers
1374     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1375     */
1376    protected function cssAlign3( MatcherFactory $matcherFactory ) {
1377        // @codeCoverageIgnoreStart
1378        if ( isset( $this->cache[__METHOD__] ) ) {
1379            return $this->cache[__METHOD__];
1380        }
1381        // @codeCoverageIgnoreEnd
1382
1383        $props = [];
1384        $normal = new KeywordMatcher( 'normal' );
1385        $normalStretch = new KeywordMatcher( [ 'normal', 'stretch' ] );
1386        $autoNormalStretch = new KeywordMatcher( [ 'auto', 'normal', 'stretch' ] );
1387        $overflowPosition = Quantifier::optional( new KeywordMatcher( [ 'safe', 'unsafe' ] ) );
1388        $baselinePosition = UnorderedGroup::allOf( [
1389            Quantifier::optional( new KeywordMatcher( [ 'first', 'last' ] ) ),
1390            new KeywordMatcher( 'baseline' )
1391        ] );
1392        $contentDistribution = new KeywordMatcher( [
1393            'space-between', 'space-around', 'space-evenly', 'stretch'
1394        ] );
1395        $overflowAndSelfPosition = new Juxtaposition( [
1396            $overflowPosition,
1397            new KeywordMatcher( [
1398                'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end',
1399            ] ),
1400        ] );
1401        $overflowAndSelfPositionLR = new Juxtaposition( [
1402            $overflowPosition,
1403            new KeywordMatcher( [
1404                'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right',
1405            ] ),
1406        ] );
1407        $overflowAndContentPos = new Juxtaposition( [
1408            $overflowPosition,
1409            new KeywordMatcher( [ 'center', 'start', 'end', 'flex-start', 'flex-end' ] ),
1410        ] );
1411        $overflowAndContentPosLR = new Juxtaposition( [
1412            $overflowPosition,
1413            new KeywordMatcher( [ 'center', 'start', 'end', 'flex-start', 'flex-end', 'left', 'right' ] ),
1414        ] );
1415
1416        $props['align-content'] = new Alternative( [
1417            $normal,
1418            $baselinePosition,
1419            $contentDistribution,
1420            $overflowAndContentPos,
1421        ] );
1422        $props['justify-content'] = new Alternative( [
1423            $normal,
1424            $contentDistribution,
1425            $overflowAndContentPosLR,
1426        ] );
1427        $props['place-content'] = new Juxtaposition( [
1428            $props['align-content'], Quantifier::optional( $props['justify-content'] )
1429        ] );
1430        $props['align-self'] = new Alternative( [
1431            $autoNormalStretch,
1432            $baselinePosition,
1433            $overflowAndSelfPosition,
1434        ] );
1435        $props['justify-self'] = new Alternative( [
1436            $autoNormalStretch,
1437            $baselinePosition,
1438            $overflowAndSelfPositionLR,
1439        ] );
1440        $props['place-self'] = new Juxtaposition( [
1441            $props['align-self'], Quantifier::optional( $props['justify-self'] )
1442        ] );
1443        $props['align-items'] = new Alternative( [
1444            $normalStretch,
1445            $baselinePosition,
1446            $overflowAndSelfPosition,
1447        ] );
1448        $props['justify-items'] = new Alternative( [
1449            $normalStretch,
1450            $baselinePosition,
1451            $overflowAndSelfPositionLR,
1452            new KeywordMatcher( 'legacy' ),
1453            UnorderedGroup::allOf( [
1454                new KeywordMatcher( 'legacy' ),
1455                new KeywordMatcher( [ 'left', 'right', 'center' ] ),
1456            ] ),
1457        ] );
1458        $props['place-items'] = new Juxtaposition( [
1459            $props['align-items'], Quantifier::optional( $props['justify-items'] )
1460        ] );
1461        $props['row-gap'] = new Alternative( [ $normal, $matcherFactory->lengthPercentage() ] );
1462        $props['column-gap'] = $props['row-gap'];
1463        $props['gap'] = new Juxtaposition( [
1464            $props['row-gap'], Quantifier::optional( $props['column-gap'] )
1465        ] );
1466
1467        $this->cache[__METHOD__] = $props;
1468        return $props;
1469    }
1470
1471    /**
1472     * Properties for CSS Fragmentation Module Level 3
1473     * @see https://www.w3.org/TR/2018/CR-css-break-3-20181204/
1474     * @param MatcherFactory $matcherFactory Factory for Matchers
1475     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1476     */
1477    protected function cssBreak3( MatcherFactory $matcherFactory ) {
1478        // @codeCoverageIgnoreStart
1479        if ( isset( $this->cache[__METHOD__] ) ) {
1480            return $this->cache[__METHOD__];
1481        }
1482        // @codeCoverageIgnoreEnd
1483
1484        $props = [];
1485        $props['break-before'] = new KeywordMatcher( [
1486            'auto', 'avoid', 'avoid-page', 'page', 'left', 'right', 'recto', 'verso', 'avoid-column',
1487            'column', 'avoid-region', 'region'
1488        ] );
1489        $props['break-after'] = $props['break-before'];
1490        $props['break-inside'] = new KeywordMatcher( [
1491            'auto', 'avoid', 'avoid-page', 'avoid-column', 'avoid-region'
1492        ] );
1493        $props['orphans'] = $matcherFactory->integer();
1494        $props['widows'] = $matcherFactory->integer();
1495        $props['box-decoration-break'] = new KeywordMatcher( [ 'slice', 'clone' ] );
1496        $props['page-break-before'] = new KeywordMatcher( [
1497            'auto', 'always', 'avoid', 'left', 'right'
1498        ] );
1499        $props['page-break-after'] = $props['page-break-before'];
1500        $props['page-break-inside'] = new KeywordMatcher( [ 'auto', 'avoid' ] );
1501
1502        $this->cache[__METHOD__] = $props;
1503        return $props;
1504    }
1505
1506    /**
1507     * Properties for CSS Grid Layout Module Level 1
1508     * @see https://www.w3.org/TR/2025/CRD-css-grid-1-20250326/
1509     * @param MatcherFactory $matcherFactory Factory for Matchers
1510     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1511     */
1512    protected function cssGrid1( MatcherFactory $matcherFactory ) {
1513        // @codeCoverageIgnoreStart
1514        if ( isset( $this->cache[__METHOD__] ) ) {
1515            return $this->cache[__METHOD__];
1516        }
1517        // @codeCoverageIgnoreEnd
1518
1519        $props = [];
1520        $comma = $matcherFactory->comma();
1521        $slash = new DelimMatcher( '/' );
1522        $customIdent = $matcherFactory->customIdent( [ 'span' ] );
1523        $lineNamesO = Quantifier::optional( new BlockMatcher(
1524            Token::T_LEFT_BRACKET, Quantifier::star( $customIdent )
1525        ) );
1526        $trackBreadth = new Alternative( [
1527            $matcherFactory->lengthPercentage(),
1528            new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1529                return $t->value() >= 0 && !strcasecmp( $t->unit(), 'fr' );
1530            } ),
1531            new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
1532        ] );
1533        $inflexibleBreadth = new Alternative( [
1534            $matcherFactory->lengthPercentage(),
1535            new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
1536        ] );
1537        $fixedBreadth = $matcherFactory->lengthPercentage();
1538        $trackSize = new Alternative( [
1539            $trackBreadth,
1540            new FunctionMatcher( 'minmax',
1541                new Juxtaposition( [ $inflexibleBreadth, $trackBreadth ], true )
1542            ),
1543            new FunctionMatcher( 'fit-content', $matcherFactory->lengthPercentage() )
1544        ] );
1545        $fixedSize = new Alternative( [
1546            $fixedBreadth,
1547            new FunctionMatcher( 'minmax', new Juxtaposition( [ $fixedBreadth, $trackBreadth ], true ) ),
1548            new FunctionMatcher( 'minmax',
1549                new Juxtaposition( [ $inflexibleBreadth, $fixedBreadth ], true )
1550            ),
1551        ] );
1552        $trackRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1553            $matcherFactory->integer(),
1554            $comma,
1555            Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
1556            $lineNamesO
1557        ] ) );
1558        $autoRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1559            new KeywordMatcher( [ 'auto-fill', 'auto-fit' ] ),
1560            $comma,
1561            Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
1562            $lineNamesO
1563        ] ) );
1564        $fixedRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1565            $matcherFactory->integer(),
1566            $comma,
1567            Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
1568            $lineNamesO
1569        ] ) );
1570        $trackList = new Juxtaposition( [
1571            Quantifier::plus( new Juxtaposition( [
1572                $lineNamesO, new Alternative( [ $trackSize, $trackRepeat ] )
1573            ] ) ),
1574            $lineNamesO
1575        ] );
1576        $autoTrackList = new Juxtaposition( [
1577            Quantifier::star( new Juxtaposition( [
1578                $lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
1579            ] ) ),
1580            $lineNamesO,
1581            $autoRepeat,
1582            Quantifier::star( new Juxtaposition( [
1583                $lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
1584            ] ) ),
1585            $lineNamesO,
1586        ] );
1587        $explicitTrackList = new Juxtaposition( [
1588            Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
1589            $lineNamesO
1590        ] );
1591        $autoDense = UnorderedGroup::allOf( [
1592            new KeywordMatcher( 'auto-flow' ),
1593            Quantifier::optional( new KeywordMatcher( 'dense' ) )
1594        ] );
1595
1596        $props['grid-template-columns'] = new Alternative( [
1597            new KeywordMatcher( 'none' ), $trackList, $autoTrackList
1598        ] );
1599        $props['grid-template-rows'] = $props['grid-template-columns'];
1600        $props['grid-template-areas'] = new Alternative( [
1601            new KeywordMatcher( 'none' ),
1602            Quantifier::plus( $matcherFactory->string() ),
1603        ] );
1604        $props['grid-template'] = new Alternative( [
1605            new KeywordMatcher( 'none' ),
1606            new Juxtaposition( [ $props['grid-template-rows'], $slash, $props['grid-template-columns'] ] ),
1607            new Juxtaposition( [
1608                Quantifier::plus( new Juxtaposition( [
1609                    $lineNamesO, $matcherFactory->string(), Quantifier::optional( $trackSize ), $lineNamesO
1610                ] ) ),
1611                Quantifier::optional( new Juxtaposition( [ $slash, $explicitTrackList ] ) ),
1612            ] )
1613        ] );
1614        $props['grid-auto-columns'] = Quantifier::plus( $trackSize );
1615        $props['grid-auto-rows'] = $props['grid-auto-columns'];
1616        $props['grid-auto-flow'] = UnorderedGroup::someOf( [
1617            new KeywordMatcher( [ 'row', 'column' ] ),
1618            new KeywordMatcher( 'dense' )
1619        ] );
1620        $props['grid'] = new Alternative( [
1621            $props['grid-template'],
1622            new Juxtaposition( [
1623                $props['grid-template-rows'],
1624                $slash,
1625                $autoDense,
1626                Quantifier::optional( $props['grid-auto-columns'] ),
1627            ] ),
1628            new Juxtaposition( [
1629                $autoDense,
1630                Quantifier::optional( $props['grid-auto-rows'] ),
1631                $slash,
1632                $props['grid-template-columns'],
1633            ] )
1634        ] );
1635
1636        $gridLine = new Alternative( [
1637            new KeywordMatcher( 'auto' ),
1638            $customIdent,
1639            UnorderedGroup::allOf( [
1640                $matcherFactory->integer(),
1641                Quantifier::optional( $customIdent )
1642            ] ),
1643            UnorderedGroup::allOf( [
1644                new KeywordMatcher( 'span' ),
1645                UnorderedGroup::someOf( [
1646                    $matcherFactory->integer(),
1647                    $customIdent,
1648                ] )
1649            ] )
1650        ] );
1651        $props['grid-row-start'] = $gridLine;
1652        $props['grid-column-start'] = $gridLine;
1653        $props['grid-row-end'] = $gridLine;
1654        $props['grid-column-end'] = $gridLine;
1655        $props['grid-row'] = new Juxtaposition( [
1656            $gridLine, Quantifier::optional( new Juxtaposition( [ $slash, $gridLine ] ) )
1657        ] );
1658        $props['grid-column'] = $props['grid-row'];
1659        $props['grid-area'] = new Juxtaposition( [
1660            $gridLine, Quantifier::count( new Juxtaposition( [ $slash, $gridLine ] ), 0, 3 )
1661        ] );
1662
1663        // Replaced by the alignment module
1664        $align = $this->cssAlign3( $matcherFactory );
1665        $props['grid-row-gap'] = $align['row-gap'];
1666        $props['grid-column-gap'] = $align['column-gap'];
1667        $props['grid-gap'] = $align['gap'];
1668
1669        // Also, these are copied from the alignment module. Copying is ok as long as
1670        // it's the identical object.
1671        $props['row-gap'] = $align['row-gap'];
1672        $props['column-gap'] = $align['column-gap'];
1673        $props['gap'] = $align['gap'];
1674        $props['justify-self'] = $align['justify-self'];
1675        $props['justify-items'] = $align['justify-items'];
1676        $props['align-self'] = $align['align-self'];
1677        $props['align-items'] = $align['align-items'];
1678        $props['justify-content'] = $align['justify-content'];
1679        $props['align-content'] = $align['align-content'];
1680
1681        $this->cache[__METHOD__] = $props;
1682        return $props;
1683    }
1684
1685    /**
1686     * Properties for CSS Filter Effects Module Level 1
1687     * @see https://www.w3.org/TR/2018/WD-filter-effects-1-20181218/
1688     * @param MatcherFactory $matcherFactory Factory for Matchers
1689     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1690     */
1691    protected function cssFilter1( MatcherFactory $matcherFactory ) {
1692        // @codeCoverageIgnoreStart
1693        if ( isset( $this->cache[__METHOD__] ) ) {
1694            return $this->cache[__METHOD__];
1695        }
1696        // @codeCoverageIgnoreEnd
1697
1698        $onp = Quantifier::optional( $matcherFactory->numberPercentage() );
1699
1700        $props = [];
1701
1702        $props['filter'] = new Alternative( [
1703            new KeywordMatcher( 'none' ),
1704            Quantifier::plus( new Alternative( [
1705                new FunctionMatcher( 'blur', Quantifier::optional( $matcherFactory->length() ) ),
1706                new FunctionMatcher( 'brightness', $onp ),
1707                new FunctionMatcher( 'contrast', $onp ),
1708                new FunctionMatcher( 'drop-shadow', UnorderedGroup::allOf( [
1709                    Quantifier::optional( $matcherFactory->color() ),
1710                    Quantifier::count( $matcherFactory->length(), 2, 3 ),
1711                ] ) ),
1712                new FunctionMatcher( 'grayscale', $onp ),
1713                new FunctionMatcher( 'hue-rotate', Quantifier::optional( new Alternative( [
1714                    $matcherFactory->zero(),
1715                    $matcherFactory->angle(),
1716                ] ) ) ),
1717                new FunctionMatcher( 'invert', $onp ),
1718                new FunctionMatcher( 'opacity', $onp ),
1719                new FunctionMatcher( 'saturate', $onp ),
1720                new FunctionMatcher( 'sepia', $onp ),
1721                $matcherFactory->url( 'svg' ),
1722            ] ) )
1723        ] );
1724        $props['flood-color'] = $matcherFactory->color();
1725        $props['flood-opacity'] = $matcherFactory->numberPercentage();
1726        $props['color-interpolation-filters'] = new KeywordMatcher( [ 'auto', 'sRGB', 'linearRGB' ] );
1727        $props['lighting-color'] = $matcherFactory->color();
1728
1729        $this->cache[__METHOD__] = $props;
1730        return $props;
1731    }
1732
1733    /**
1734     * Shapes and masking share these basic shapes
1735     * @see https://www.w3.org/TR/2022/CRD-css-shapes-1-20221115/#basic-shape-functions
1736     * @param MatcherFactory $matcherFactory Factory for Matchers
1737     * @return Matcher
1738     */
1739    protected function basicShapes( MatcherFactory $matcherFactory ) {
1740        // @codeCoverageIgnoreStart
1741        if ( isset( $this->cache[__METHOD__] ) ) {
1742            return $this->cache[__METHOD__];
1743        }
1744        // @codeCoverageIgnoreEnd
1745
1746        $border = $this->cssBackgrounds3( $matcherFactory );
1747        $lp = $matcherFactory->lengthPercentage();
1748        $sr = new Alternative( [
1749            $lp,
1750            new KeywordMatcher( [ 'closest-side', 'farthest-side' ] ),
1751        ] );
1752        $optRound = Quantifier::optional( new Juxtaposition( [
1753            new KeywordMatcher( 'round' ), $border['border-radius']
1754        ] ) );
1755        $fillRule = new KeywordMatcher( [ 'nonzero', 'evenodd' ] );
1756
1757        $basicShape = new Alternative( [
1758            new FunctionMatcher( 'inset', new Juxtaposition( [
1759                Quantifier::count( $lp, 1, 4 ),
1760                $optRound,
1761            ] ) ),
1762            new FunctionMatcher( 'xywh', new Juxtaposition( [
1763                Quantifier::count( $lp, 2, 2 ),
1764                Quantifier::count( $lp, 2, 2 ),
1765                $optRound,
1766            ] ) ),
1767            new FunctionMatcher( 'rect', new Juxtaposition( [
1768                Quantifier::count(
1769                    new Alternative( [ $lp, new KeywordMatcher( 'auto' ) ] ),
1770                    4, 4
1771                ),
1772                $optRound,
1773            ] ) ),
1774            new FunctionMatcher( 'circle', new Juxtaposition( [
1775                Quantifier::optional( $sr ),
1776                Quantifier::optional( new Juxtaposition( [
1777                    new KeywordMatcher( 'at' ), $matcherFactory->position()
1778                ] ) )
1779            ] ) ),
1780            new FunctionMatcher( 'ellipse', new Juxtaposition( [
1781                Quantifier::optional( Quantifier::count( $sr, 2, 2 ) ),
1782                Quantifier::optional( new Juxtaposition( [
1783                    new KeywordMatcher( 'at' ), $matcherFactory->position()
1784                ] ) )
1785            ] ) ),
1786            new FunctionMatcher( 'polygon', new Juxtaposition( [
1787                Quantifier::optional( $fillRule ),
1788                Quantifier::hash( Quantifier::count( $lp, 2, 2 ) ),
1789            ], true ) ),
1790            new FunctionMatcher( 'path', new Juxtaposition( [
1791                Quantifier::optional( $fillRule ),
1792                $matcherFactory->string(),
1793            ], true ) ),
1794        ] );
1795
1796        $this->cache[__METHOD__] = $basicShape;
1797        return $basicShape;
1798    }
1799
1800    /**
1801     * Properties for CSS Shapes Module Level 1
1802     * @see https://www.w3.org/TR/2022/CRD-css-shapes-1-20221115/
1803     * @param MatcherFactory $matcherFactory Factory for Matchers
1804     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1805     */
1806    protected function cssShapes1( MatcherFactory $matcherFactory ) {
1807        // @codeCoverageIgnoreStart
1808        if ( isset( $this->cache[__METHOD__] ) ) {
1809            return $this->cache[__METHOD__];
1810        }
1811        // @codeCoverageIgnoreEnd
1812
1813        $shapeBoxKW = $this->backgroundTypes( $matcherFactory )['boxKeywords'];
1814        $shapeBoxKW[] = 'margin-box';
1815
1816        $props = [];
1817
1818        $props['shape-outside'] = new Alternative( [
1819            new KeywordMatcher( 'none' ),
1820            UnorderedGroup::someOf( [
1821                $this->basicShapes( $matcherFactory ),
1822                new KeywordMatcher( $shapeBoxKW ),
1823            ] ),
1824            $matcherFactory->url( 'image' ),
1825        ] );
1826        $props['shape-image-threshold'] = $matcherFactory->numberPercentage();
1827        $props['shape-margin'] = $matcherFactory->lengthPercentage();
1828
1829        $this->cache[__METHOD__] = $props;
1830        return $props;
1831    }
1832
1833    /**
1834     * Properties for CSS Masking Module Level 1
1835     * @see https://www.w3.org/TR/2021/CRD-css-masking-1-20210805/
1836     * @param MatcherFactory $matcherFactory Factory for Matchers
1837     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1838     */
1839    protected function cssMasking1( MatcherFactory $matcherFactory ) {
1840        // @codeCoverageIgnoreStart
1841        if ( isset( $this->cache[__METHOD__] ) ) {
1842            return $this->cache[__METHOD__];
1843        }
1844        // @codeCoverageIgnoreEnd
1845
1846        $slash = new DelimMatcher( '/' );
1847        $bgtypes = $this->backgroundTypes( $matcherFactory );
1848        $bg = $this->cssBackgrounds3( $matcherFactory );
1849
1850        // <geometry-box> = <shape-box> | fill-box | stroke-box | view-box
1851        // <shape-box> = <box> | margin-box
1852        // The changelog says margin-box was removed from the mask-origin and
1853        // mask-clip properties, but the grammar still allows it, so we will
1854        // allow it. <shape-box> in Shapes 1 refers to <box> in Backgrounds 3,
1855        // but it's not there anymore, <box> has moved to Box 4 which is where
1856        // we're getting it from. <box> now allows all four of these extra
1857        // keywords so this complexity is redundant. It's just a <box>.
1858        $geometryBoxKeywords = $this->boxEdgeKeywords()['box'];
1859        $geometryBox = new KeywordMatcher( $geometryBoxKeywords );
1860        $maskRef = new Alternative( [
1861            new KeywordMatcher( 'none' ),
1862            $matcherFactory->image(),
1863            $matcherFactory->url( 'svg' ),
1864        ] );
1865        $maskMode = new KeywordMatcher( [ 'alpha', 'luminance', 'match-source' ] );
1866        $maskClip = new KeywordMatcher( array_merge( $geometryBoxKeywords, [ 'no-clip' ] ) );
1867        $maskComposite = new KeywordMatcher( [ 'add', 'subtract', 'intersect', 'exclude' ] );
1868
1869        $props = [];
1870
1871        $props['clip-path'] = new Alternative( [
1872            $matcherFactory->url( 'svg' ),
1873            UnorderedGroup::someOf( [
1874                $this->basicShapes( $matcherFactory ),
1875                $geometryBox,
1876            ] ),
1877            new KeywordMatcher( 'none' ),
1878        ] );
1879        $props['clip-rule'] = new KeywordMatcher( [ 'nonzero', 'evenodd' ] );
1880        $props['mask-image'] = Quantifier::hash( $maskRef );
1881        $props['mask-mode'] = Quantifier::hash( $maskMode );
1882        $props['mask-repeat'] = $bg['background-repeat'];
1883        $props['mask-position'] = Quantifier::hash( $matcherFactory->position() );
1884        $props['mask-clip'] = Quantifier::hash( $maskClip );
1885        $props['mask-origin'] = Quantifier::hash( $geometryBox );
1886        $props['mask-size'] = $bg['background-size'];
1887        $props['mask-composite'] = Quantifier::hash( $maskComposite );
1888        $props['mask'] = Quantifier::hash( UnorderedGroup::someOf( [
1889            $maskRef,
1890            new Juxtaposition( [
1891                $matcherFactory->position(),
1892                Quantifier::optional( new Juxtaposition( [ $slash, $bgtypes['bgsize'] ] ) ),
1893            ] ),
1894            $bgtypes['bgrepeat'],
1895            $geometryBox,
1896            $maskClip,
1897            $maskComposite,
1898            $maskMode
1899        ] ) );
1900        $props['mask-border-source'] = new Alternative( [
1901            new KeywordMatcher( 'none' ),
1902            $matcherFactory->image(),
1903        ] );
1904        $props['mask-border-mode'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
1905        // Different from border-image-slice, sigh
1906        $props['mask-border-slice'] = new Juxtaposition( [
1907            Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
1908            Quantifier::optional( new KeywordMatcher( 'fill' ) ),
1909        ] );
1910        $props['mask-border-width'] = $bg['border-image-width'];
1911        $props['mask-border-outset'] = $bg['border-image-outset'];
1912        $props['mask-border-repeat'] = $bg['border-image-repeat'];
1913        $props['mask-border'] = UnorderedGroup::someOf( [
1914            $props['mask-border-source'],
1915            new Juxtaposition( [
1916                $props['mask-border-slice'],
1917                Quantifier::optional( new Juxtaposition( [
1918                    $slash,
1919                    Quantifier::optional( $props['mask-border-width'] ),
1920                    Quantifier::optional( new Juxtaposition( [
1921                        $slash,
1922                        $props['mask-border-outset'],
1923                    ] ) ),
1924                ] ) ),
1925            ] ),
1926            $props['mask-border-repeat'],
1927            $props['mask-border-mode'],
1928        ] );
1929        $props['mask-type'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
1930
1931        $this->cache[__METHOD__] = $props;
1932        return $props;
1933    }
1934
1935    /**
1936     * Additional keywords and functions from CSS Box Sizing Level 3
1937     * @see https://www.w3.org/TR/2021/WD-css-sizing-3-20211217/#column-sizing
1938     * @param MatcherFactory $matcherFactory Factory for Matchers
1939     * @return Matcher[] Array of matchers
1940     */
1941    protected function getSizingAdditions3( MatcherFactory $matcherFactory ) {
1942        if ( !isset( $this->cache[__METHOD__] ) ) {
1943            $lengthPct = $matcherFactory->lengthPercentage();
1944            $this->cache[__METHOD__] = [
1945                new KeywordMatcher( [
1946                    'max-content', 'min-content',
1947                ] ),
1948                new FunctionMatcher( 'fit-content', $lengthPct ),
1949                // Browser-prefixed versions of the function, needed by Firefox as of January 2020
1950                new FunctionMatcher( '-moz-fit-content', $lengthPct ),
1951            ];
1952        }
1953        return $this->cache[__METHOD__];
1954    }
1955
1956    /**
1957     * Additional keywords and functions from CSS Box Sizing Level 3 and 4
1958     * @see https://www.w3.org/TR/css-sizing-3/#sizing-values
1959     * @see https://www.w3.org/TR/css-sizing-4/#sizing-values
1960     * @param MatcherFactory $matcherFactory Factory for Matchers
1961     * @return Matcher[] Array of matchers
1962     */
1963    protected function getSizingAdditions( MatcherFactory $matcherFactory ) {
1964        if ( !isset( $this->cache[__METHOD__] ) ) {
1965            $lengthPct = $matcherFactory->lengthPercentage();
1966            $this->cache[__METHOD__] = [
1967                new KeywordMatcher( [
1968                    'max-content', 'min-content', 'stretch', 'fit-content', 'contain'
1969                ] ),
1970                // fit-content() as a function https://developer.mozilla.org/en-US/docs/Web/CSS/fit-content_function
1971                new FunctionMatcher( 'fit-content', $lengthPct ),
1972                // Prefixed for FF v3-v93 (until 2021) https://caniuse.com/?search=fit-content
1973                new FunctionMatcher( '-moz-fit-content', $lengthPct ),
1974            ];
1975        }
1976        return $this->cache[__METHOD__];
1977    }
1978
1979    /**
1980     * Properties for CSS Box Sizing Level 3 and 4
1981     * @see https://www.w3.org/TR/css-sizing-3/#sizing-values
1982     * @see https://www.w3.org/TR/css-sizing-4/#sizing-values
1983     * @param MatcherFactory $matcherFactory Factory for Matchers
1984     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1985     */
1986    protected function cssSizing4( MatcherFactory $matcherFactory ) {
1987        // @codeCoverageIgnoreStart
1988        if ( isset( $this->cache[__METHOD__] ) ) {
1989            return $this->cache[__METHOD__];
1990        }
1991        // @codeCoverageIgnoreEnd
1992
1993        $none = new KeywordMatcher( 'none' );
1994        $auto = new KeywordMatcher( 'auto' );
1995        $lengthPct = $matcherFactory->lengthPercentage();
1996        $sizingValues = array_merge( [ $lengthPct ], $this->getSizingAdditions( $matcherFactory ) );
1997
1998        $props = [];
1999        $props['width'] = new Alternative( array_merge( [ $auto ], $sizingValues ) );
2000        $props['min-width'] = $props['width'];
2001        $props['max-width'] = new Alternative( array_merge( [ $none ], $sizingValues ) );
2002        $props['height'] = $props['width'];
2003        $props['min-height'] = $props['min-width'];
2004        $props['max-height'] = $props['max-width'];
2005
2006        $props['box-sizing'] = new KeywordMatcher( [ 'content-box', 'border-box' ] );
2007        // https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio
2008        // auto || <ratio>
2009        $props['aspect-ratio'] = UnorderedGroup::someOf( [ $auto, $matcherFactory->ratio() ] );
2010        // https://developer.mozilla.org/en-US/docs/Web/CSS/contain-intrinsic-size
2011        // auto? [ none | <length> ]
2012        $containIntrinsic = new Juxtaposition( [
2013            Quantifier::optional( $auto ),
2014            new Alternative( [
2015                $none,
2016                $lengthPct,
2017            ] ),
2018        ] );
2019        $props['contain-intrinsic-width'] = $containIntrinsic;
2020        $props['contain-intrinsic-height'] = $containIntrinsic;
2021        $props['contain-intrinsic-block-size'] = $containIntrinsic;
2022        $props['contain-intrinsic-inline-size'] = $containIntrinsic;
2023        $props['contain-intrinsic-size'] = Quantifier::count( $containIntrinsic, 1, 2 );
2024        // https://drafts.csswg.org/css-sizing-4/#intrinsic-contribution-override
2025        // legacy | zero-if-scroll || zero-if-extrinsic
2026        $props['min-intrinsic-sizing'] = new Alternative( [
2027            new KeywordMatcher( 'legacy' ),
2028            UnorderedGroup::someOf( [
2029                new KeywordMatcher( 'zero-if-scroll' ),
2030                new KeywordMatcher( 'zero-if-extrinsic' ),
2031            ] ),
2032        ] );
2033
2034        $this->cache[__METHOD__] = $props;
2035        return $props;
2036    }
2037
2038    /**
2039     * Properties for CSS Logical 1
2040     * @see https://www.w3.org/TR/2018/WD-css-logical-1-20180827/
2041     * @param MatcherFactory $matcherFactory Factory for Matchers
2042     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
2043     */
2044    protected function cssLogical1( MatcherFactory $matcherFactory ) {
2045        // @codeCoverageIgnoreStart
2046        if ( isset( $this->cache[__METHOD__] ) ) {
2047            return $this->cache[__METHOD__];
2048        }
2049        // @codeCoverageIgnoreEnd
2050
2051        $cssSizing4 = $this->cssSizing4( $matcherFactory );
2052        $css2 = $this->css2( $matcherFactory );
2053        $cssBorderBackground3 = $this->cssBackgrounds3( $matcherFactory );
2054        $borderCombo = UnorderedGroup::someOf( [
2055            $cssBorderBackground3['border-top-width'],
2056            $cssBorderBackground3['border-top-style'],
2057            $matcherFactory->color(),
2058        ] );
2059
2060        $props = [
2061            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#dimension-properties
2062            'block-size' => $cssSizing4['width'],
2063            'inline-size' => $cssSizing4['width'],
2064            'min-block-size' => $cssSizing4['min-width'],
2065            'min-inline-size' => $cssSizing4['min-width'],
2066            'max-block-size' => $cssSizing4['max-width'],
2067            'max-inline-size' => $cssSizing4['max-width'],
2068
2069            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#margin-properties
2070            'margin-block-start' => $css2['margin-top'],
2071            'margin-block-end' => $css2['margin-top'],
2072            'margin-inline-start' => $css2['margin-top'],
2073            'margin-inline-end' => $css2['margin-top'],
2074            'margin-block' => Quantifier::count( $css2['margin-top'], 1, 2 ),
2075            'margin-inline' => Quantifier::count( $css2['margin-top'], 1, 2 ),
2076
2077            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#inset-properties
2078            // Superseded by Position 3
2079
2080            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#padding-properties
2081            'padding-block-start' => $css2['padding-top'],
2082            'padding-block-end' => $css2['padding-top'],
2083            'padding-inline-start' => $css2['padding-top'],
2084            'padding-inline-end' => $css2['padding-top'],
2085            'padding-block' => Quantifier::count( $css2['padding-top'], 1, 2 ),
2086            'padding-inline' => Quantifier::count( $css2['padding-top'], 1, 2 ),
2087
2088            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#border-width
2089            'border-block-start-width' => $cssBorderBackground3['border-top-width'],
2090            'border-block-end-width' => $cssBorderBackground3['border-top-width'],
2091            'border-inline-start-width' => $cssBorderBackground3['border-top-width'],
2092            'border-inline-end-width' => $cssBorderBackground3['border-top-width'],
2093            'border-block-width' => Quantifier::count( $cssBorderBackground3['border-top-width'], 1, 2 ),
2094            'border-inline-width' => Quantifier::count( $cssBorderBackground3['border-top-width'], 1, 2 ),
2095
2096            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#border-style
2097            'border-block-start-style' => $cssBorderBackground3['border-top-style'],
2098            'border-block-end-style' => $cssBorderBackground3['border-top-style'],
2099            'border-inline-start-style' => $cssBorderBackground3['border-top-style'],
2100            'border-inline-end-style' => $cssBorderBackground3['border-top-style'],
2101            'border-block-style' => Quantifier::count( $cssBorderBackground3['border-top-style'], 1, 2 ),
2102            'border-inline-style' => Quantifier::count( $cssBorderBackground3['border-top-style'], 1, 2 ),
2103
2104            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#border-color
2105            'border-block-start-color' => $cssBorderBackground3['border-top-color'],
2106            'border-block-end-color' => $cssBorderBackground3['border-top-color'],
2107            'border-inline-start-color' => $cssBorderBackground3['border-top-color'],
2108            'border-inline-end-color' => $cssBorderBackground3['border-top-color'],
2109            'border-block-color' => Quantifier::count( $cssBorderBackground3['border-top-color'], 1, 2 ),
2110            'border-inline-color' => Quantifier::count( $cssBorderBackground3['border-top-color'], 1, 2 ),
2111
2112            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#border-shorthands
2113            'border-block-start' => $borderCombo,
2114            'border-block-end' => $borderCombo,
2115            'border-inline-start' => $borderCombo,
2116            'border-inline-end' => $borderCombo,
2117            // both are equivalent to 'border-block-start' per the spec
2118            'border-block' => $borderCombo,
2119            'border-inline' => $borderCombo,
2120
2121            // https://www.w3.org/TR/2018/WD-css-logical-1-20180827/#border-radius-shorthands
2122            'border-start-start-radius' => $cssBorderBackground3['border-top-left-radius'],
2123            'border-start-end-radius' => $cssBorderBackground3['border-top-left-radius'],
2124            'border-end-start-radius' => $cssBorderBackground3['border-top-left-radius'],
2125            'border-end-end-radius' => $cssBorderBackground3['border-top-left-radius'],
2126        ];
2127
2128        $this->cache[__METHOD__] = $props;
2129        return $props;
2130    }
2131
2132    /**
2133     * Properties for CSS Ruby Annotation Layout Module Level 1
2134     * @see https://www.w3.org/TR/2022/WD-css-ruby-1-20221231/
2135     * @param MatcherFactory $matcherFactory
2136     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
2137     */
2138    protected function cssRuby1( $matcherFactory ) {
2139        return $this->cache[__METHOD__] ??= [
2140            'ruby-position' => new Alternative( [
2141                UnorderedGroup::someOf( [
2142                    new KeywordMatcher( 'alternate' ),
2143                    new KeywordMatcher( [ 'over', 'under' ] ),
2144                ] ),
2145                new KeywordMatcher( [ 'inter-character' ] )
2146            ] ),
2147            'ruby-merge' => new KeywordMatcher( [ 'separate', 'merge', 'auto' ] ),
2148            'ruby-align' => new KeywordMatcher( [
2149                'start', 'center', 'space-between', 'space-around'
2150            ] ),
2151            'ruby-overhang' => new KeywordMatcher( [ 'auto', 'none' ] ),
2152        ];
2153    }
2154
2155    /**
2156     * CSS Lists and Counters Module Level 3
2157     * @see https://www.w3.org/TR/2020/WD-css-lists-3-20201117/
2158     *
2159     * @param MatcherFactory $matcherFactory
2160     * @return Matcher[]
2161     */
2162    protected function cssLists3( MatcherFactory $matcherFactory ) {
2163        // @codeCoverageIgnoreStart
2164        if ( isset( $this->cache[__METHOD__] ) ) {
2165            return $this->cache[__METHOD__];
2166        }
2167        // @codeCoverageIgnoreEnd
2168        $none = new KeywordMatcher( 'none' );
2169        $props = [];
2170
2171        $props['counter-increment'] = $props['counter-reset'] = $props['counter-set'] =
2172            new Alternative( [
2173                Quantifier::plus( new Juxtaposition( [
2174                    $matcherFactory->customIdent( [ 'none' ] ),
2175                    Quantifier::optional( $matcherFactory->integer() )
2176                ] ) ),
2177                $none
2178            ] );
2179
2180        $props['list-style-image'] = new Alternative( [
2181            $matcherFactory->image(),
2182            $none
2183        ] );
2184
2185        $props['list-style-position'] = new KeywordMatcher( [ 'inside', 'outside' ] );
2186
2187        $props['list-style-type'] = new Alternative( [
2188            $matcherFactory->counterStyle(),
2189            $matcherFactory->string(),
2190            $none
2191        ] );
2192
2193        $props['list-style'] = UnorderedGroup::someOf( [
2194            $props['list-style-position'], $props['list-style-image'], $props['list-style-type']
2195        ] );
2196
2197        $props['marker-side'] = new KeywordMatcher( [ 'match-self', 'match-parent' ] );
2198
2199        $this->cache[__METHOD__] = $props;
2200        return $props;
2201    }
2202
2203    /**
2204     * Properties for CSS Scroll Snap Module Level 1
2205     * @see https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/
2206     * @param MatcherFactory $matcherFactory
2207     * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
2208     */
2209    protected function cssScrollSnap1( MatcherFactory $matcherFactory ) {
2210        // @codeCoverageIgnoreStart
2211        if ( isset( $this->cache[__METHOD__] ) ) {
2212            return $this->cache[__METHOD__];
2213        }
2214        // @codeCoverageIgnoreEnd
2215
2216        $props = [];
2217        $none = new KeywordMatcher( 'none' );
2218        $auto = new KeywordMatcher( 'auto' );
2219        $length = $matcherFactory->length();
2220        $lp = $matcherFactory->lengthPercentage();
2221
2222        // https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/#scroll-snap-type
2223        $props['scroll-snap-type'] = new Alternative( [
2224            $none,
2225            new Juxtaposition( [
2226                new KeywordMatcher( [ 'x', 'y', 'block', 'inline', 'both' ] ),
2227                Quantifier::optional( new KeywordMatcher( [ 'mandatory', 'proximity' ] ) ),
2228            ] ),
2229        ] );
2230
2231        // https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/#scroll-padding
2232        $props['scroll-padding'] = Quantifier::count( new Alternative( [ $auto, $lp ] ), 1, 4 );
2233
2234        $props['scroll-padding-top'] = new Alternative( [ $auto, $lp ] );
2235        $props['scroll-padding-right'] = new Alternative( [ $auto, $lp ] );
2236        $props['scroll-padding-bottom'] = new Alternative( [ $auto, $lp ] );
2237        $props['scroll-padding-left'] = new Alternative( [ $auto, $lp ] );
2238
2239        $props['scroll-padding-inline'] = Quantifier::count( new Alternative( [ $auto, $lp ] ), 1, 2 );
2240        $props['scroll-padding-inline-start'] = new Alternative( [ $auto, $lp ] );
2241        $props['scroll-padding-inline-end'] = new Alternative( [ $auto, $lp ] );
2242        $props['scroll-padding-block'] = Quantifier::count( new Alternative( [ $auto, $lp ] ), 1, 2 );
2243        $props['scroll-padding-block-start'] = new Alternative( [ $auto, $lp ] );
2244        $props['scroll-padding-block-end'] = new Alternative( [ $auto, $lp ] );
2245
2246        // https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/#scroll-margin
2247        $props['scroll-margin'] = Quantifier::count( $length, 1, 4 );
2248
2249        $props['scroll-margin-top'] = $length;
2250        $props['scroll-margin-right'] = $length;
2251        $props['scroll-margin-bottom'] = $length;
2252        $props['scroll-margin-left'] = $length;
2253
2254        $props['scroll-margin-inline'] = Quantifier::count( $length, 1, 2 );
2255        $props['scroll-margin-inline-start'] = $length;
2256        $props['scroll-margin-inline-end'] = $length;
2257        $props['scroll-margin-block'] = Quantifier::count( $length, 1, 2 );
2258        $props['scroll-margin-block-start'] = $length;
2259        $props['scroll-margin-block-end'] = $length;
2260
2261        // https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/#scroll-snap-align
2262        $props['scroll-snap-align'] = Quantifier::count( new KeywordMatcher( [ 'none', 'start', 'end', 'center' ] ),
2263            1, 2 );
2264
2265        // https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/#scroll-snap-stop
2266        $props['scroll-snap-stop'] = new KeywordMatcher( [ 'normal', 'always' ] );
2267
2268        $this->cache[__METHOD__] = $props;
2269        return $props;
2270    }
2271}