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