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/2016/CR-css-cascade-3-20160519/#all-shorthand
41			'all' => $matcherFactory->cssWideKeywords(),
42
43			// https://www.w3.org/TR/2015/REC-pointerevents-20150224/#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/2013/WD-css3-page-20130314/#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->cssBorderBackground3( $matcherFactory ) );
60		$this->addKnownProperties( $this->cssImages3( $matcherFactory ) );
61		$this->addKnownProperties( $this->cssFonts3( $matcherFactory ) );
62		$this->addKnownProperties( $this->cssMulticol( $matcherFactory ) );
63		$this->addKnownProperties( $this->cssOverflow3( $matcherFactory ) );
64		$this->addKnownProperties( $this->cssUI4( $matcherFactory ) );
65		$this->addKnownProperties( $this->cssCompositing1( $matcherFactory ) );
66		$this->addKnownProperties( $this->cssWritingModes3( $matcherFactory ) );
67		$this->addKnownProperties( $this->cssTransitions( $matcherFactory ) );
68		$this->addKnownProperties( $this->cssAnimations( $matcherFactory ) );
69		$this->addKnownProperties( $this->cssFlexbox3( $matcherFactory ) );
70		$this->addKnownProperties( $this->cssTransforms1( $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->cssSpeech( $matcherFactory ) );
76		$this->addKnownProperties( $this->cssGrid1( $matcherFactory ) );
77		$this->addKnownProperties( $this->cssFilter1( $matcherFactory ) );
78		$this->addKnownProperties( $this->cssShapes1( $matcherFactory ) );
79		$this->addKnownProperties( $this->cssMasking1( $matcherFactory ) );
80		$this->addKnownProperties( $this->cssSizing3( $matcherFactory ) );
81	}
82
83	/**
84	 * Properties from CSS 2.1
85	 * @see https://www.w3.org/TR/2011/REC-CSS2-20110607/
86	 * @note Omits properties that have been replaced by a CSS3 module
87	 * @param MatcherFactory $matcherFactory Factory for Matchers
88	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
89	 */
90	protected function css2( MatcherFactory $matcherFactory ) {
91		// @codeCoverageIgnoreStart
92		if ( isset( $this->cache[__METHOD__] ) ) {
93			return $this->cache[__METHOD__];
94		}
95		// @codeCoverageIgnoreEnd
96
97		$props = [];
98
99		$none = new KeywordMatcher( 'none' );
100		$auto = new KeywordMatcher( 'auto' );
101		$autoLength = new Alternative( [ $auto, $matcherFactory->length() ] );
102		$autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
103
104		// https://www.w3.org/TR/2011/REC-CSS2-20110607/box.html
105		$props['margin-top'] = $autoLengthPct;
106		$props['margin-bottom'] = $autoLengthPct;
107		$props['margin-left'] = $autoLengthPct;
108		$props['margin-right'] = $autoLengthPct;
109		$props['margin'] = Quantifier::count( $autoLengthPct, 1, 4 );
110		$props['padding-top'] = $matcherFactory->lengthPercentage();
111		$props['padding-bottom'] = $matcherFactory->lengthPercentage();
112		$props['padding-left'] = $matcherFactory->lengthPercentage();
113		$props['padding-right'] = $matcherFactory->lengthPercentage();
114		$props['padding'] = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
115
116		// https://www.w3.org/TR/2011/REC-CSS2-20110607/visuren.html
117		$props['float'] = new KeywordMatcher( [ 'left', 'right', 'none' ] );
118		$props['clear'] = new KeywordMatcher( [ 'none', 'left', 'right', 'both' ] );
119
120		// https://www.w3.org/TR/2011/REC-CSS2-20110607/visudet.html
121		$props['line-height'] = new Alternative( [
122			new KeywordMatcher( 'normal' ),
123			$matcherFactory->length(),
124			$matcherFactory->numberPercentage(),
125		] );
126		$props['vertical-align'] = new Alternative( [
127			new KeywordMatcher( [
128				'baseline', 'sub', 'super', 'top', 'text-top', 'middle', 'bottom', 'text-bottom'
129			] ),
130			$matcherFactory->lengthPercentage(),
131		] );
132
133		// https://www.w3.org/TR/2011/REC-CSS2-20110607/visufx.html
134		$props['clip'] = new Alternative( [
135			$auto, new FunctionMatcher( 'rect', Quantifier::hash( $autoLength, 4, 4 ) ),
136		] );
137		$props['visibility'] = new KeywordMatcher( [ 'visible', 'hidden', 'collapse' ] );
138
139		// https://www.w3.org/TR/2011/REC-CSS2-20110607/generate.html
140		$props['list-style-type'] = new KeywordMatcher( [
141			'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero', 'lower-roman', 'upper-roman',
142			'lower-greek', 'lower-latin', 'upper-latin', 'armenian', 'georgian', 'lower-alpha',
143			'upper-alpha', 'none'
144		] );
145		$props['content'] = new Alternative( [
146			new KeywordMatcher( [ 'normal', 'none' ] ),
147			Quantifier::plus( new Alternative( [
148				$matcherFactory->string(),
149				$matcherFactory->image(), // Replaces <url> per https://www.w3.org/TR/css3-images/#placement
150				new FunctionMatcher( 'counter', new Juxtaposition( [
151					$matcherFactory->ident(),
152					Quantifier::optional( $props['list-style-type'] ),
153				], true ) ),
154				new FunctionMatcher( 'counters', new Juxtaposition( [
155					$matcherFactory->ident(),
156					$matcherFactory->string(),
157					Quantifier::optional( $props['list-style-type'] ),
158				], true ) ),
159				new FunctionMatcher( 'attr', $matcherFactory->ident() ),
160				new KeywordMatcher( [ 'open-quote', 'close-quote', 'no-open-quote', 'no-close-quote' ] ),
161			] ) )
162		] );
163		$props['quotes'] = new Alternative( [
164			$none, Quantifier::plus( new Juxtaposition( [
165				$matcherFactory->string(), $matcherFactory->string()
166			] ) ),
167		] );
168		$props['counter-reset'] = new Alternative( [
169			$none,
170			Quantifier::plus( new Juxtaposition( [
171				$matcherFactory->ident(), Quantifier::optional( $matcherFactory->integer() )
172			] ) ),
173		] );
174		$props['counter-increment'] = $props['counter-reset'];
175		$props['list-style-image'] = new Alternative( [
176			$none,
177			$matcherFactory->image() // Replaces <url> per https://www.w3.org/TR/css3-images/#placement
178		] );
179		$props['list-style-position'] = new KeywordMatcher( [ 'inside', 'outside' ] );
180		$props['list-style'] = UnorderedGroup::someOf( [
181			$props['list-style-type'], $props['list-style-position'], $props['list-style-image']
182		] );
183
184		// https://www.w3.org/TR/2011/REC-CSS2-20110607/tables.html
185		$props['caption-side'] = new KeywordMatcher( [ 'top', 'bottom' ] );
186		$props['table-layout'] = new KeywordMatcher( [ 'auto', 'fixed' ] );
187		$props['border-collapse'] = new KeywordMatcher( [ 'collapse', 'separate' ] );
188		$props['border-spacing'] = Quantifier::count( $matcherFactory->length(), 1, 2 );
189		$props['empty-cells'] = new KeywordMatcher( [ 'show', 'hide' ] );
190
191		$this->cache[__METHOD__] = $props;
192		return $props;
193	}
194
195	/**
196	 * Properties for CSS Display Module Level 3
197	 * @see https://www.w3.org/TR/2018/WD-css-display-3-20180420/
198	 * @param MatcherFactory $matcherFactory Factory for Matchers
199	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
200	 */
201	protected function cssDisplay3( MatcherFactory $matcherFactory ) {
202		// @codeCoverageIgnoreStart
203		if ( isset( $this->cache[__METHOD__] ) ) {
204			return $this->cache[__METHOD__];
205		}
206		// @codeCoverageIgnoreEnd
207
208		$props = [];
209
210		$displayOutside = new KeywordMatcher( [ 'block', 'inline', 'run-in' ] );
211
212		$props['display'] = new Alternative( [
213			UnorderedGroup::someOf( [ // <display-outside> || <display-inside>
214				$displayOutside,
215				new KeywordMatcher( [ 'flow', 'flow-root', 'table', 'flex', 'grid', 'ruby' ] ),
216			] ),
217			UnorderedGroup::allOf( [ // <display-listitem>
218				Quantifier::optional( $displayOutside ),
219				Quantifier::optional( new KeywordMatcher( [ 'flow', 'flow-root' ] ) ),
220				new KeywordMatcher( 'list-item' ),
221			] ),
222			new KeywordMatcher( [
223				// <display-internal>
224				'table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-cell',
225				'table-column-group', 'table-column', 'table-caption', 'ruby-base', 'ruby-text',
226				'ruby-base-container', 'ruby-text-container',
227				// <display-box>
228				'contents', 'none',
229				// <display-legacy>
230				'inline-block', 'inline-table', 'inline-flex', 'inline-grid',
231			] ),
232		] );
233
234		$this->cache[__METHOD__] = $props;
235		return $props;
236	}
237
238	/**
239	 * Properties for CSS Positioned Layout Module Level 3
240	 * @see https://www.w3.org/TR/2016/WD-css-position-3-20160517/
241	 * @param MatcherFactory $matcherFactory Factory for Matchers
242	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
243	 */
244	protected function cssPosition3( MatcherFactory $matcherFactory ) {
245		// @codeCoverageIgnoreStart
246		if ( isset( $this->cache[__METHOD__] ) ) {
247			return $this->cache[__METHOD__];
248		}
249		// @codeCoverageIgnoreEnd
250
251		$auto = new KeywordMatcher( 'auto' );
252		$autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
253
254		$props = [];
255
256		$props['position'] = new KeywordMatcher( [
257			'static', 'relative', 'absolute', 'sticky', 'fixed'
258		] );
259		$props['top'] = $autoLengthPct;
260		$props['right'] = $autoLengthPct;
261		$props['bottom'] = $autoLengthPct;
262		$props['left'] = $autoLengthPct;
263		$props['offset-before'] = $autoLengthPct;
264		$props['offset-after'] = $autoLengthPct;
265		$props['offset-start'] = $autoLengthPct;
266		$props['offset-end'] = $autoLengthPct;
267		$props['z-index'] = new Alternative( [ $auto, $matcherFactory->integer() ] );
268
269		$this->cache[__METHOD__] = $props;
270		return $props;
271	}
272
273	/**
274	 * Properties for CSS Color Module Level 3
275	 * @see https://www.w3.org/TR/2018/PR-css-color-3-20180315/
276	 * @param MatcherFactory $matcherFactory Factory for Matchers
277	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
278	 */
279	protected function cssColor3( MatcherFactory $matcherFactory ) {
280		// @codeCoverageIgnoreStart
281		if ( isset( $this->cache[__METHOD__] ) ) {
282			return $this->cache[__METHOD__];
283		}
284		// @codeCoverageIgnoreEnd
285
286		$props = [];
287		$props['color'] = $matcherFactory->color();
288		$props['opacity'] = $matcherFactory->number();
289
290		$this->cache[__METHOD__] = $props;
291		return $props;
292	}
293
294	/**
295	 * Data types for backgrounds
296	 * @param MatcherFactory $matcherFactory Factory for Matchers
297	 * @return array
298	 */
299	protected function backgroundTypes( MatcherFactory $matcherFactory ) {
300		// @codeCoverageIgnoreStart
301		if ( isset( $this->cache[__METHOD__] ) ) {
302			return $this->cache[__METHOD__];
303		}
304		// @codeCoverageIgnoreEnd
305
306		$types = [];
307
308		$types['bgrepeat'] = new Alternative( [
309			new KeywordMatcher( [ 'repeat-x', 'repeat-y' ] ),
310			Quantifier::count( new KeywordMatcher( [ 'repeat', 'space', 'round', 'no-repeat' ] ), 1, 2 ),
311		] );
312		$types['bgsize'] = new Alternative( [
313			Quantifier::count( new Alternative( [
314				$matcherFactory->lengthPercentage(),
315				new KeywordMatcher( 'auto' )
316			] ), 1, 2 ),
317			new KeywordMatcher( [ 'cover', 'contain' ] )
318		] );
319		$types['boxKeywords'] = [ 'border-box', 'padding-box', 'content-box' ];
320
321		$this->cache[__METHOD__] = $types;
322		return $types;
323	}
324
325	/**
326	 * Properties for CSS Backgrounds and Borders Module Level 3
327	 * @see https://www.w3.org/TR/2017/CR-css-backgrounds-3-20171017/
328	 * @param MatcherFactory $matcherFactory Factory for Matchers
329	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
330	 */
331	protected function cssBorderBackground3( MatcherFactory $matcherFactory ) {
332		// @codeCoverageIgnoreStart
333		if ( isset( $this->cache[__METHOD__] ) ) {
334			return $this->cache[__METHOD__];
335		}
336		// @codeCoverageIgnoreEnd
337
338		$props = [];
339
340		$types = $this->backgroundTypes( $matcherFactory );
341		$slash = new DelimMatcher( '/' );
342		$bgimage = new Alternative( [ new KeywordMatcher( 'none' ), $matcherFactory->image() ] );
343		$bgrepeat = $types['bgrepeat'];
344		$bgattach = new KeywordMatcher( [ 'scroll', 'fixed', 'local' ] );
345		$position = $matcherFactory->position();
346		$box = new KeywordMatcher( $types['boxKeywords'] );
347		$bgsize = $types['bgsize'];
348		$bglayer = UnorderedGroup::someOf( [
349			$bgimage,
350			new Juxtaposition( [
351				$position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
352			] ),
353			$bgrepeat,
354			$bgattach,
355			$box,
356			$box,
357		] );
358		$finalBglayer = UnorderedGroup::someOf( [
359			$matcherFactory->color(),
360			$bgimage,
361			new Juxtaposition( [
362				$position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
363			] ),
364			$bgrepeat,
365			$bgattach,
366			$box,
367			$box,
368		] );
369
370		$props['background-color'] = $matcherFactory->color();
371		$props['background-image'] = Quantifier::hash( $bgimage );
372		$props['background-repeat'] = Quantifier::hash( $bgrepeat );
373		$props['background-attachment'] = Quantifier::hash( $bgattach );
374		$props['background-position'] = Quantifier::hash( $position );
375		$props['background-clip'] = Quantifier::hash( $box );
376		$props['background-origin'] = $props['background-clip'];
377		$props['background-size'] = Quantifier::hash( $bgsize );
378		$props['background'] = new Juxtaposition(
379			[ Quantifier::hash( $bglayer, 0, INF ), $finalBglayer ], true
380		);
381
382		$lineStyle = new KeywordMatcher( [
383			'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'
384		] );
385		$lineWidth = new Alternative( [
386			new KeywordMatcher( [ 'thin', 'medium', 'thick' ] ), $matcherFactory->length(),
387		] );
388		$borderCombo = UnorderedGroup::someOf( [ $lineWidth, $lineStyle, $matcherFactory->color() ] );
389		$radius = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 2 );
390		$radius4 = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
391
392		$props['border-top-color'] = $matcherFactory->color();
393		$props['border-right-color'] = $matcherFactory->color();
394		$props['border-bottom-color'] = $matcherFactory->color();
395		$props['border-left-color'] = $matcherFactory->color();
396		$props['border-color'] = Quantifier::count( $matcherFactory->color(), 1, 4 );
397		$props['border-top-style'] = $lineStyle;
398		$props['border-right-style'] = $lineStyle;
399		$props['border-bottom-style'] = $lineStyle;
400		$props['border-left-style'] = $lineStyle;
401		$props['border-style'] = Quantifier::count( $lineStyle, 1, 4 );
402		$props['border-top-width'] = $lineWidth;
403		$props['border-right-width'] = $lineWidth;
404		$props['border-bottom-width'] = $lineWidth;
405		$props['border-left-width'] = $lineWidth;
406		$props['border-width'] = Quantifier::count( $lineWidth, 1, 4 );
407		$props['border-top'] = $borderCombo;
408		$props['border-right'] = $borderCombo;
409		$props['border-bottom'] = $borderCombo;
410		$props['border-left'] = $borderCombo;
411		$props['border'] = $borderCombo;
412		$props['border-top-left-radius'] = $radius;
413		$props['border-top-right-radius'] = $radius;
414		$props['border-bottom-left-radius'] = $radius;
415		$props['border-bottom-right-radius'] = $radius;
416		$props['border-radius'] = new Juxtaposition( [
417			$radius4, Quantifier::optional( new Juxtaposition( [ $slash, $radius4 ] ) )
418		] );
419		$props['border-image-source'] = new Alternative( [
420			new KeywordMatcher( 'none' ),
421			$matcherFactory->image()
422		] );
423		$props['border-image-slice'] = UnorderedGroup::allOf( [
424			Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
425			Quantifier::optional( new KeywordMatcher( 'fill' ) ),
426		] );
427		$props['border-image-width'] = Quantifier::count( new Alternative( [
428			$matcherFactory->length(),
429			$matcherFactory->percentage(),
430			$matcherFactory->number(),
431			new KeywordMatcher( 'auto' ),
432		] ), 1, 4 );
433		$props['border-image-outset'] = Quantifier::count( new Alternative( [
434			$matcherFactory->length(),
435			$matcherFactory->number(),
436		] ), 1, 4 );
437		$props['border-image-repeat'] = Quantifier::count( new KeywordMatcher( [
438			'stretch', 'repeat', 'round', 'space'
439		] ), 1, 2 );
440		$props['border-image'] = UnorderedGroup::someOf( [
441			$props['border-image-source'],
442			new Juxtaposition( [
443				$props['border-image-slice'],
444				Quantifier::optional( new Alternative( [
445					new Juxtaposition( [ $slash, $props['border-image-width'] ] ),
446					new Juxtaposition( [
447						$slash,
448						Quantifier::optional( $props['border-image-width'] ),
449						$slash,
450						$props['border-image-outset']
451					] )
452				] ) )
453			] ),
454			$props['border-image-repeat']
455		] );
456
457		$props['box-shadow'] = new Alternative( [
458			new KeywordMatcher( 'none' ),
459			Quantifier::hash( UnorderedGroup::allOf( [
460				Quantifier::optional( new KeywordMatcher( 'inset' ) ),
461				Quantifier::count( $matcherFactory->length(), 2, 4 ),
462				Quantifier::optional( $matcherFactory->color() ),
463			] ) )
464		] );
465
466		$this->cache[__METHOD__] = $props;
467		return $props;
468	}
469
470	/**
471	 * Properties for CSS Image Values and Replaced Content Module Level 3
472	 * @see https://www.w3.org/TR/2012/CR-css3-images-20120417/
473	 * @param MatcherFactory $matcherFactory Factory for Matchers
474	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
475	 */
476	protected function cssImages3( MatcherFactory $matcherFactory ) {
477		// @codeCoverageIgnoreStart
478		if ( isset( $this->cache[__METHOD__] ) ) {
479			return $this->cache[__METHOD__];
480		}
481		// @codeCoverageIgnoreEnd
482
483		$props = [];
484
485		$props['object-fit'] = new KeywordMatcher( [ 'fill', 'contain', 'cover', 'none', 'scale-down' ] );
486		$props['object-position'] = $matcherFactory->position();
487		$props['image-resolution'] = UnorderedGroup::allOf( [
488			UnorderedGroup::someOf( [
489				new KeywordMatcher( 'from-image' ),
490				$matcherFactory->resolution(),
491			] ),
492			Quantifier::optional( new KeywordMatcher( 'snap' ) )
493		] );
494		$props['image-orientation'] = $matcherFactory->angle();
495
496		$this->cache[__METHOD__] = $props;
497		return $props;
498	}
499
500	/**
501	 * Properties for CSS Fonts Module Level 3
502	 * @see https://www.w3.org/TR/2018/CR-css-fonts-3-20180315/
503	 * @param MatcherFactory $matcherFactory Factory for Matchers
504	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
505	 */
506	protected function cssFonts3( MatcherFactory $matcherFactory ) {
507		// @codeCoverageIgnoreStart
508		if ( isset( $this->cache[__METHOD__] ) ) {
509			return $this->cache[__METHOD__];
510		}
511		// @codeCoverageIgnoreEnd
512
513		$css2 = $this->css2( $matcherFactory );
514		$props = [];
515
516		$matchData = FontFaceAtRuleSanitizer::fontMatchData( $matcherFactory );
517
518		// Note: <generic-family> is syntactically a subset of <family-name>,
519		// so no point in separately listing it.
520		$props['font-family'] = Quantifier::hash( $matchData['familyName'] );
521		$props['font-weight'] = new Alternative( [
522			new KeywordMatcher( [ 'normal', 'bold', 'bolder', 'lighter' ] ),
523			$matchData['numWeight'],
524		] );
525		$props['font-stretch'] = $matchData['font-stretch'];
526		$props['font-style'] = $matchData['font-style'];
527		$props['font-size'] = new Alternative( [
528			new KeywordMatcher( [
529				'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'larger', 'smaller'
530			] ),
531			$matcherFactory->lengthPercentage(),
532		] );
533		$props['font-size-adjust'] = new Alternative( [
534			new KeywordMatcher( 'none' ), $matcherFactory->number()
535		] );
536		$props['font'] = new Alternative( [
537			new Juxtaposition( [
538				Quantifier::optional( UnorderedGroup::someOf( [
539					$props['font-style'],
540					new KeywordMatcher( [ 'normal', 'small-caps' ] ),
541					$props['font-weight'],
542					$props['font-stretch'],
543				] ) ),
544				$props['font-size'],
545				Quantifier::optional( new Juxtaposition( [
546					new DelimMatcher( '/' ),
547					$css2['line-height'],
548				] ) ),
549				$props['font-family'],
550			] ),
551			new KeywordMatcher( [ 'caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar' ] )
552		] );
553		$props['font-synthesis'] = new Alternative( [
554			new KeywordMatcher( 'none' ),
555			UnorderedGroup::someOf( [
556				new KeywordMatcher( 'weight' ),
557				new KeywordMatcher( 'style' ),
558			] )
559		] );
560		$props['font-kerning'] = new KeywordMatcher( [ 'auto', 'normal', 'none' ] );
561		$props['font-variant-ligatures'] = new Alternative( [
562			new KeywordMatcher( [ 'normal', 'none' ] ),
563			UnorderedGroup::someOf( $matchData['ligatures'] )
564		] );
565		$props['font-variant-position'] = new KeywordMatcher(
566			array_merge( [ 'normal' ], $matchData['positionKeywords'] )
567		);
568		$props['font-variant-caps'] = new KeywordMatcher(
569			array_merge( [ 'normal' ], $matchData['capsKeywords'] )
570		);
571		$props['font-variant-numeric'] = new Alternative( [
572			new KeywordMatcher( 'normal' ),
573			UnorderedGroup::someOf( $matchData['numeric'] )
574		] );
575		$props['font-variant-alternates'] = new Alternative( [
576			new KeywordMatcher( 'normal' ),
577			UnorderedGroup::someOf( $matchData['alt'] )
578		] );
579		$props['font-variant-east-asian'] = new Alternative( [
580			new KeywordMatcher( 'normal' ),
581			UnorderedGroup::someOf( $matchData['eastAsian'] )
582		] );
583		$props['font-variant'] = $matchData['font-variant'];
584		$props['font-feature-settings'] = $matchData['font-feature-settings'];
585
586		$this->cache[__METHOD__] = $props;
587		return $props;
588	}
589
590	/**
591	 * Properties for CSS Multi-column Layout Module
592	 * @see https://www.w3.org/TR/2017/WD-css-multicol-1-20171005/
593	 * @param MatcherFactory $matcherFactory Factory for Matchers
594	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
595	 */
596	protected function cssMulticol( MatcherFactory $matcherFactory ) {
597		// @codeCoverageIgnoreStart
598		if ( isset( $this->cache[__METHOD__] ) ) {
599			return $this->cache[__METHOD__];
600		}
601		// @codeCoverageIgnoreEnd
602
603		$borders = $this->cssBorderBackground3( $matcherFactory );
604		$props = [];
605
606		$auto = new KeywordMatcher( 'auto' );
607		$normal = new KeywordMatcher( 'normal' );
608
609		$props['column-width'] = new Alternative( array_merge(
610			[ $matcherFactory->length(), $auto ],
611			// Additional values from https://www.w3.org/TR/2018/WD-css-sizing-3-20180304/
612			$this->getSizingAdditions( $matcherFactory )
613		) );
614		$props['column-count'] = new Alternative( [ $matcherFactory->integer(), $auto ] );
615		$props['columns'] = UnorderedGroup::someOf( [ $props['column-width'], $props['column-count'] ] );
616		// Copy these from similar items in the Border module
617		$props['column-rule-color'] = $borders['border-right-color'];
618		$props['column-rule-style'] = $borders['border-right-style'];
619		$props['column-rule-width'] = $borders['border-right-width'];
620		$props['column-rule'] = $borders['border-right'];
621		$props['column-span'] = new KeywordMatcher( [ 'none', 'all' ] );
622		$props['column-fill'] = new KeywordMatcher( [ 'auto', 'balance', 'balance-all' ] );
623
624		// Copy these from cssBreak3(), the duplication is allowed as long as
625		// they're the identical Matcher object.
626		$breaks = $this->cssBreak3( $matcherFactory );
627		$props['break-before'] = $breaks['break-before'];
628		$props['break-after'] = $breaks['break-after'];
629		$props['break-inside'] = $breaks['break-inside'];
630
631		// And one from cssAlign3
632		$props['column-gap'] = $this->cssAlign3( $matcherFactory )['column-gap'];
633
634		$this->cache[__METHOD__] = $props;
635		return $props;
636	}
637
638	/**
639	 * Properties for CSS Overflow Module Level 3
640	 * @see https://www.w3.org/TR/2016/WD-css-overflow-3-20160531/
641	 * @param MatcherFactory $matcherFactory Factory for Matchers
642	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
643	 */
644	protected function cssOverflow3( MatcherFactory $matcherFactory ) {
645		// @codeCoverageIgnoreStart
646		if ( isset( $this->cache[__METHOD__] ) ) {
647			return $this->cache[__METHOD__];
648		}
649		// @codeCoverageIgnoreEnd
650
651		$props = [];
652
653		$props['overflow'] = new KeywordMatcher( [ 'visible', 'hidden', 'clip', 'scroll', 'auto' ] );
654		$props['overflow-x'] = $props['overflow'];
655		$props['overflow-y'] = $props['overflow'];
656		$props['max-lines'] = new Alternative( [
657			new KeywordMatcher( 'none' ), $matcherFactory->integer()
658		] );
659
660		$this->cache[__METHOD__] = $props;
661		return $props;
662	}
663
664	/**
665	 * Properties for CSS Basic User Interface Module Level 4
666	 * @see https://www.w3.org/TR/2017/PR-css-ui-3-20171214/
667	 * @see https://www.w3.org/TR/2017/WD-css-ui-4-20171222/
668	 * @param MatcherFactory $matcherFactory Factory for Matchers
669	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
670	 */
671	protected function cssUI4( MatcherFactory $matcherFactory ) {
672		// @codeCoverageIgnoreStart
673		if ( isset( $this->cache[__METHOD__] ) ) {
674			return $this->cache[__METHOD__];
675		}
676		// @codeCoverageIgnoreEnd
677
678		$border = $this->cssBorderBackground3( $matcherFactory );
679		$props = [];
680
681		$props['box-sizing'] = new KeywordMatcher( [ 'content-box', 'border-box' ] );
682		// Copy these from similar border properties
683		$props['outline-width'] = $border['border-top-width'];
684		$props['outline-style'] = new Alternative( [
685			new KeywordMatcher( 'auto' ), $border['border-top-style']
686		] );
687		$props['outline-color'] = new Alternative( [
688			new KeywordMatcher( 'invert' ), $matcherFactory->color()
689		] );
690		$props['outline'] = UnorderedGroup::someOf( [
691			$props['outline-width'], $props['outline-style'], $props['outline-color']
692		] );
693		$props['outline-offset'] = $matcherFactory->length();
694		$props['resize'] = new KeywordMatcher( [ 'none', 'both', 'horizontal', 'vertical' ] );
695		$props['text-overflow'] = Quantifier::count( new Alternative( [
696			new KeywordMatcher( [ 'clip', 'ellipsis', 'fade' ] ),
697			new FunctionMatcher( 'fade', $matcherFactory->lengthPercentage() ),
698			$matcherFactory->string(),
699		] ), 1, 2 );
700		$props['cursor'] = new Juxtaposition( [
701			Quantifier::star( new Juxtaposition( [
702				$matcherFactory->image(),
703				Quantifier::optional( new Juxtaposition( [
704					$matcherFactory->number(), $matcherFactory->number()
705				] ) ),
706				$matcherFactory->comma(),
707			] ) ),
708			new KeywordMatcher( [
709				'auto', 'default', 'none', 'context-menu', 'help', 'pointer', 'progress', 'wait', 'cell',
710				'crosshair', 'text', 'vertical-text', 'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab',
711				'grabbing', 'e-resize', 'n-resize', 'ne-resize', 'nw-resize', 's-resize', 'se-resize',
712				'sw-resize', 'w-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'col-resize',
713				'row-resize', 'all-scroll', 'zoom-in', 'zoom-out',
714			] ),
715		] );
716		$props['caret-color'] = new Alternative( [
717			new KeywordMatcher( 'auto' ), $matcherFactory->color()
718		] );
719		$props['caret-shape'] = new KeywordMatcher( [ 'auto', 'bar', 'block', 'underscore' ] );
720		$props['caret'] = UnorderedGroup::someOf( [ $props['caret-color'], $props['caret-shape'] ] );
721		$props['nav-up'] = new Alternative( [
722			new KeywordMatcher( 'auto' ),
723			new Juxtaposition( [
724				$matcherFactory->cssID(),
725				Quantifier::optional( new Alternative( [
726					new KeywordMatcher( [ 'current', 'root' ] ),
727					$matcherFactory->string(),
728				] ) )
729			] )
730		] );
731		$props['nav-right'] = $props['nav-up'];
732		$props['nav-down'] = $props['nav-up'];
733		$props['nav-left'] = $props['nav-up'];
734
735		$props['user-select'] = new KeywordMatcher( [ 'auto', 'text', 'none', 'contain', 'all' ] );
736		// Seems potentially useful enough to let the prefixed versions work.
737		$props['-moz-user-select'] = $props['user-select'];
738		$props['-ms-user-select'] = $props['user-select'];
739		$props['-webkit-user-select'] = $props['user-select'];
740
741		$props['appearance'] = new KeywordMatcher( [ 'auto', 'none' ] );
742
743		$this->cache[__METHOD__] = $props;
744		return $props;
745	}
746
747	/**
748	 * Properties for CSS Compositing and Blending Level 1
749	 * @see https://www.w3.org/TR/2015/CR-compositing-1-20150113/
750	 * @param MatcherFactory $matcherFactory Factory for Matchers
751	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
752	 */
753	protected function cssCompositing1( MatcherFactory $matcherFactory ) {
754		// @codeCoverageIgnoreStart
755		if ( isset( $this->cache[__METHOD__] ) ) {
756			return $this->cache[__METHOD__];
757		}
758		// @codeCoverageIgnoreEnd
759
760		$props = [];
761
762		$props['mix-blend-mode'] = new KeywordMatcher( [
763			'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn',
764			'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'
765		] );
766		$props['isolation'] = new KeywordMatcher( [ 'auto', 'isolate' ] );
767
768		// The linked spec incorrectly has this without the hash, despite the
769		// textual description and examples showing it as such. The draft has it fixed.
770		$props['background-blend-mode'] = Quantifier::hash( $props['mix-blend-mode'] );
771
772		$this->cache[__METHOD__] = $props;
773		return $props;
774	}
775
776	/**
777	 * Properties for CSS Writing Modes Level 3
778	 * @see https://www.w3.org/TR/2017/CR-css-writing-modes-3-20171207/
779	 * @param MatcherFactory $matcherFactory Factory for Matchers
780	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
781	 */
782	protected function cssWritingModes3( MatcherFactory $matcherFactory ) {
783		// @codeCoverageIgnoreStart
784		if ( isset( $this->cache[__METHOD__] ) ) {
785			return $this->cache[__METHOD__];
786		}
787		// @codeCoverageIgnoreEnd
788
789		$props = [];
790
791		$props['direction'] = new KeywordMatcher( [ 'ltr', 'rtl' ] );
792		$props['unicode-bidi'] = new KeywordMatcher( [
793			'normal', 'embed', 'isolate', 'bidi-override', 'isolate-override', 'plaintext'
794		] );
795		$props['writing-mode'] = new KeywordMatcher( [
796			'horizontal-tb', 'vertical-rl', 'vertical-lr',
797		] );
798		$props['text-orientation'] = new KeywordMatcher( [ 'mixed', 'upright', 'sideways' ] );
799		$props['text-combine-upright'] = new KeywordMatcher( [ 'none', 'all' ] );
800
801		$this->cache[__METHOD__] = $props;
802		return $props;
803	}
804
805	/**
806	 * Properties for CSS Transitions
807	 * @see https://www.w3.org/TR/2017/WD-css-transitions-1-20171130/
808	 * @param MatcherFactory $matcherFactory Factory for Matchers
809	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
810	 */
811	protected function cssTransitions( MatcherFactory $matcherFactory ) {
812		// @codeCoverageIgnoreStart
813		if ( isset( $this->cache[__METHOD__] ) ) {
814			return $this->cache[__METHOD__];
815		}
816		// @codeCoverageIgnoreEnd
817
818		$props = [];
819		$property = new Alternative( [
820			new KeywordMatcher( [ 'all' ] ),
821			$matcherFactory->customIdent( [ 'none' ] ),
822		] );
823		$none = new KeywordMatcher( 'none' );
824		$singleTimingFunction = $matcherFactory->cssSingleTimingFunction();
825
826		$props['transition-property'] = new Alternative( [
827			$none, Quantifier::hash( $property )
828		] );
829		$props['transition-duration'] = Quantifier::hash( $matcherFactory->time() );
830		$props['transition-timing-function'] = Quantifier::hash( $singleTimingFunction );
831		$props['transition-delay'] = Quantifier::hash( $matcherFactory->time() );
832		$props['transition'] = Quantifier::hash( UnorderedGroup::someOf( [
833			new Alternative( [ $none, $property ] ),
834			$matcherFactory->time(),
835			$singleTimingFunction,
836			$matcherFactory->time(),
837		] ) );
838
839		$this->cache[__METHOD__] = $props;
840		return $props;
841	}
842
843	/**
844	 * Properties for CSS Animations
845	 * @see https://www.w3.org/TR/2017/WD-css-animations-1-20171130/
846	 * @param MatcherFactory $matcherFactory Factory for Matchers
847	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
848	 */
849	protected function cssAnimations( MatcherFactory $matcherFactory ) {
850		// @codeCoverageIgnoreStart
851		if ( isset( $this->cache[__METHOD__] ) ) {
852			return $this->cache[__METHOD__];
853		}
854		// @codeCoverageIgnoreEnd
855
856		$props = [];
857		$name = new Alternative( [
858			new KeywordMatcher( [ 'none' ] ),
859			$matcherFactory->customIdent( [ 'none' ] ),
860			$matcherFactory->string(),
861		] );
862		$singleTimingFunction = $matcherFactory->cssSingleTimingFunction();
863		$count = new Alternative( [
864			new KeywordMatcher( 'infinite' ),
865			$matcherFactory->number()
866		] );
867		$direction = new KeywordMatcher( [ 'normal', 'reverse', 'alternate', 'alternate-reverse' ] );
868		$playState = new KeywordMatcher( [ 'running', 'paused' ] );
869		$fillMode = new KeywordMatcher( [ 'none', 'forwards', 'backwards', 'both' ] );
870
871		$props['animation-name'] = Quantifier::hash( $name );
872		$props['animation-duration'] = Quantifier::hash( $matcherFactory->time() );
873		$props['animation-timing-function'] = Quantifier::hash( $singleTimingFunction );
874		$props['animation-iteration-count'] = Quantifier::hash( $count );
875		$props['animation-direction'] = Quantifier::hash( $direction );
876		$props['animation-play-state'] = Quantifier::hash( $playState );
877		$props['animation-delay'] = Quantifier::hash( $matcherFactory->time() );
878		$props['animation-fill-mode'] = Quantifier::hash( $fillMode );
879		$props['animation'] = Quantifier::hash( UnorderedGroup::someOf( [
880			$matcherFactory->time(),
881			$singleTimingFunction,
882			$matcherFactory->time(),
883			$count,
884			$direction,
885			$fillMode,
886			$playState,
887			$name,
888		] ) );
889
890		$this->cache[__METHOD__] = $props;
891		return $props;
892	}
893
894	/**
895	 * Properties for CSS Flexible Box Layout Module Level 1
896	 * @see https://www.w3.org/TR/2017/CR-css-flexbox-1-20171019/
897	 * @param MatcherFactory $matcherFactory Factory for Matchers
898	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
899	 */
900	protected function cssFlexbox3( MatcherFactory $matcherFactory ) {
901		// @codeCoverageIgnoreStart
902		if ( isset( $this->cache[__METHOD__] ) ) {
903			return $this->cache[__METHOD__];
904		}
905		// @codeCoverageIgnoreEnd
906
907		$props = [];
908		$props['flex-direction'] = new KeywordMatcher( [
909			'row', 'row-reverse', 'column', 'column-reverse'
910		] );
911		$props['flex-wrap'] = new KeywordMatcher( [ 'nowrap', 'wrap', 'wrap-reverse' ] );
912		$props['flex-flow'] = UnorderedGroup::someOf( [ $props['flex-direction'], $props['flex-wrap'] ] );
913		$props['order'] = $matcherFactory->integer();
914		$props['flex-grow'] = $matcherFactory->number();
915		$props['flex-shrink'] = $matcherFactory->number();
916		$props['flex-basis'] = new Alternative( [
917			new KeywordMatcher( [ 'content' ] ),
918			$this->cssSizing3( $matcherFactory )['width']
919		] );
920		$props['flex'] = new Alternative( [
921			new KeywordMatcher( 'none' ),
922			UnorderedGroup::someOf( [
923				new Juxtaposition( [ $props['flex-grow'], Quantifier::optional( $props['flex-shrink'] ) ] ),
924				$props['flex-basis'],
925			] )
926		] );
927
928		// The alignment module supersedes the ones in flexbox. Copying is ok as long as
929		// it's the identical object.
930		$align = $this->cssAlign3( $matcherFactory );
931		$props['justify-content'] = $align['justify-content'];
932		$props['align-items'] = $align['align-items'];
933		$props['align-self'] = $align['align-self'];
934		$props['align-content'] = $align['align-content'];
935
936		$this->cache[__METHOD__] = $props;
937		return $props;
938	}
939
940	/**
941	 * Properties for CSS Transforms Module Level 1 and CSS 3D Transforms Level 3
942	 *
943	 * Combined because they both define "transform"
944	 *
945	 * @see https://www.w3.org/TR/2017/WD-css-transforms-1-20171130/
946	 * @see https://www.w3.org/TR/2009/WD-css3-3d-transforms-20090320/
947	 * @param MatcherFactory $matcherFactory Factory for Matchers
948	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
949	 */
950	protected function cssTransforms1( MatcherFactory $matcherFactory ) {
951		// @codeCoverageIgnoreStart
952		if ( isset( $this->cache[__METHOD__] ) ) {
953			return $this->cache[__METHOD__];
954		}
955		// @codeCoverageIgnoreEnd
956
957		$props = [];
958		$a = $matcherFactory->angle();
959		$n = $matcherFactory->number();
960		$lp = $matcherFactory->lengthPercentage();
961		$ol = Quantifier::optional( $matcherFactory->length() );
962		$center = new KeywordMatcher( 'center' );
963		$leftRight = new KeywordMatcher( [ 'left', 'right' ] );
964		$topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
965
966		$props['transform'] = new Alternative( [
967			new KeywordMatcher( 'none' ),
968			Quantifier::plus( new Alternative( [
969				// From https://www.w3.org/TR/2017/WD-css-transforms-1-20171130/#transform-functions
970				new FunctionMatcher( 'matrix', Quantifier::hash( $n, 6, 6 ) ),
971				new FunctionMatcher( 'translate', Quantifier::hash( $lp, 1, 2 ) ),
972				new FunctionMatcher( 'translateX', $lp ),
973				new FunctionMatcher( 'translateY', $lp ),
974				new FunctionMatcher( 'scale', Quantifier::hash( $n, 1, 2 ) ),
975				new FunctionMatcher( 'scaleX', $n ),
976				new FunctionMatcher( 'scaleY', $n ),
977				new FunctionMatcher( 'rotate', $a ),
978				new FunctionMatcher( 'skew', Quantifier::hash( $a, 1, 2 ) ),
979				new FunctionMatcher( 'skewX', $a ),
980				new FunctionMatcher( 'skewY', $a ),
981
982				// From https://www.w3.org/TR/2009/WD-css3-3d-transforms-20090320/#transform-functions
983				new FunctionMatcher( 'matrix3d', Quantifier::hash( $n, 16, 16 ) ),
984				new FunctionMatcher( 'translate3d', Quantifier::hash( $lp, 3, 3 ) ),
985				new FunctionMatcher( 'translateZ', $lp ),
986				new FunctionMatcher( 'scale3d', Quantifier::hash( $n, 3, 3 ) ),
987				new FunctionMatcher( 'scaleZ', $n ),
988				new FunctionMatcher( 'rotate3d', new Juxtaposition( [ $n, $n, $n, $a ], true ) ),
989				new FunctionMatcher( 'rotateX', $a ),
990				new FunctionMatcher( 'rotateY', $a ),
991				new FunctionMatcher( 'rotateZ', $a ),
992				new FunctionMatcher( 'perspective', $n ),
993			] ) )
994		] );
995
996		// From https://www.w3.org/TR/2017/WD-css-transforms-1-20171130/
997		$props['transform-origin'] = new Alternative( [
998			new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
999			new Juxtaposition( [
1000				new Alternative( [ $center, $leftRight, $lp ] ),
1001				new Alternative( [ $center, $topBottom, $lp ] ),
1002				$ol
1003			] ),
1004			new Juxtaposition( [
1005				UnorderedGroup::allOf( [
1006					new Alternative( [ $center, $leftRight ] ),
1007					new Alternative( [ $center, $topBottom ] ),
1008				] ),
1009				$ol,
1010			] )
1011		] );
1012		$props['transform-box'] = new KeywordMatcher( [ 'border-box', 'fill-box', 'view-box' ] );
1013
1014		// From https://www.w3.org/TR/2009/WD-css3-3d-transforms-20090320/
1015		$props['transform-style'] = new KeywordMatcher( [ 'flat', 'preserve-3d' ] );
1016		$props['perspective'] = new Alternative( [ new KeywordMatcher( 'none' ), $n ] );
1017		$props['perspective-origin'] = new Alternative( [
1018			new Juxtaposition( [
1019				new Alternative( [ $center, $leftRight, $lp ] ),
1020				Quantifier::optional( new Alternative( [ $center, $topBottom, $lp ] ) ),
1021			] ),
1022			UnorderedGroup::someOf( [
1023				new Alternative( [ $center, $leftRight ] ),
1024				new Alternative( [ $center, $topBottom ] ),
1025			] )
1026		] );
1027		$props['backface-visibility'] = new KeywordMatcher( [ 'visible', 'hidden' ] );
1028
1029		$this->cache[__METHOD__] = $props;
1030		return $props;
1031	}
1032
1033	/**
1034	 * Properties for CSS Text Module Level 3
1035	 * @see https://www.w3.org/TR/2017/WD-css-text-3-20170822/
1036	 * @param MatcherFactory $matcherFactory Factory for Matchers
1037	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1038	 */
1039	protected function cssText3( MatcherFactory $matcherFactory ) {
1040		// @codeCoverageIgnoreStart
1041		if ( isset( $this->cache[__METHOD__] ) ) {
1042			return $this->cache[__METHOD__];
1043		}
1044		// @codeCoverageIgnoreEnd
1045
1046		$props = [];
1047
1048		$props['text-transform'] = new KeywordMatcher( [
1049			'none', 'capitalize', 'uppercase', 'lowercase', 'full-width'
1050		] );
1051		$props['white-space'] = new KeywordMatcher( [
1052			'normal', 'pre', 'nowrap', 'pre-wrap', 'pre-line'
1053		] );
1054		$props['tab-size'] = new Alternative( [ $matcherFactory->number(), $matcherFactory->length() ] );
1055		$props['line-break'] = new KeywordMatcher( [ 'auto', 'loose', 'normal', 'strict', 'anywhere' ] );
1056		$props['word-break'] = new KeywordMatcher( [ 'normal', 'keep-all', 'break-all', 'break-word' ] );
1057		$props['hyphens'] = new KeywordMatcher( [ 'none', 'manual', 'auto' ] );
1058		$props['word-wrap'] = new Alternative( [
1059			new KeywordMatcher( [ 'normal' ] ),
1060			UnorderedGroup::someOf( [
1061				new KeywordMatcher( [ 'break-word' ] ),
1062				new KeywordMatcher( [ 'break-spaces' ] ),
1063			] )
1064		] );
1065		$props['overflow-wrap'] = $props['word-wrap'];
1066		$props['text-align'] = new KeywordMatcher( [
1067			'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent', 'justify-all'
1068		] );
1069		$props['text-align-all'] = new KeywordMatcher( [
1070			'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent'
1071		] );
1072		$props['text-align-last'] = new KeywordMatcher( [
1073			'auto', 'start', 'end', 'left', 'right', 'center', 'justify'
1074		] );
1075		$props['text-justify'] = new KeywordMatcher( [
1076			'auto', 'none', 'inter-word', 'inter-character'
1077		] );
1078		$props['word-spacing'] = new Alternative( [
1079			new KeywordMatcher( 'normal' ),
1080			$matcherFactory->lengthPercentage()
1081		] );
1082		$props['letter-spacing'] = new Alternative( [
1083			new KeywordMatcher( 'normal' ),
1084			$matcherFactory->length()
1085		] );
1086		$props['text-indent'] = UnorderedGroup::allOf( [
1087			$matcherFactory->lengthPercentage(),
1088			Quantifier::optional( new KeywordMatcher( 'hanging' ) ),
1089			Quantifier::optional( new KeywordMatcher( 'each-line' ) ),
1090		] );
1091		$props['hanging-punctuation'] = new Alternative( [
1092			new KeywordMatcher( 'none' ),
1093			UnorderedGroup::someOf( [
1094				new KeywordMatcher( 'first' ),
1095				new KeywordMatcher( [ 'force-end', 'allow-end' ] ),
1096				new KeywordMatcher( 'last' ),
1097			] )
1098		] );
1099
1100		$this->cache[__METHOD__] = $props;
1101		return $props;
1102	}
1103
1104	/**
1105	 * Properties for CSS ext Decoration Module Level 3
1106	 * @see https://www.w3.org/TR/2013/CR-css-text-decor-3-20130801/
1107	 * @param MatcherFactory $matcherFactory Factory for Matchers
1108	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1109	 */
1110	protected function cssTextDecor3( MatcherFactory $matcherFactory ) {
1111		// @codeCoverageIgnoreStart
1112		if ( isset( $this->cache[__METHOD__] ) ) {
1113			return $this->cache[__METHOD__];
1114		}
1115		// @codeCoverageIgnoreEnd
1116
1117		$props = [];
1118
1119		$props['text-decoration-line'] = new Alternative( [
1120			new KeywordMatcher( 'none' ),
1121			UnorderedGroup::someOf( [
1122				new KeywordMatcher( 'underline' ),
1123				new KeywordMatcher( 'overline' ),
1124				new KeywordMatcher( 'line-through' ),
1125				// new KeywordMatcher( 'blink' ), // NOOO!!!
1126			] )
1127		] );
1128		$props['text-decoration-color'] = $matcherFactory->color();
1129		$props['text-decoration-style'] = new KeywordMatcher( [
1130			'solid', 'double', 'dotted', 'dashed', 'wavy'
1131		] );
1132		$props['text-decoration'] = UnorderedGroup::someOf( [
1133			$props['text-decoration-line'],
1134			$props['text-decoration-style'],
1135			$props['text-decoration-color'],
1136		] );
1137		$props['text-decoration-skip'] = new Alternative( [
1138			new KeywordMatcher( 'none' ),
1139			UnorderedGroup::someOf( [
1140				new KeywordMatcher( 'objects' ),
1141				new KeywordMatcher( 'spaces' ),
1142				new KeywordMatcher( 'ink' ),
1143				new KeywordMatcher( 'edges' ),
1144				new KeywordMatcher( 'box-decoration' ),
1145			] )
1146		] );
1147		$props['text-underline-position'] = new Alternative( [
1148			new KeywordMatcher( 'auto' ),
1149			UnorderedGroup::someOf( [
1150				new KeywordMatcher( 'under' ),
1151				new KeywordMatcher( [ 'left', 'right' ] ),
1152			] )
1153		] );
1154		$props['text-emphasis-style'] = new Alternative( [
1155			new KeywordMatcher( 'none' ),
1156			UnorderedGroup::someOf( [
1157				new KeywordMatcher( [ 'filled', 'open' ] ),
1158				new KeywordMatcher( [ 'dot', 'circle', 'double-circle', 'triangle', 'sesame' ] )
1159			] ),
1160			$matcherFactory->string(),
1161		] );
1162		$props['text-emphasis-color'] = $matcherFactory->color();
1163		$props['text-emphasis'] = UnorderedGroup::someOf( [
1164			$props['text-emphasis-style'],
1165			$props['text-emphasis-color'],
1166		] );
1167		$props['text-emphasis-position'] = UnorderedGroup::allOf( [
1168			new KeywordMatcher( [ 'over', 'under' ] ),
1169			new KeywordMatcher( [ 'right', 'left' ] ),
1170		] );
1171		$props['text-shadow'] = new Alternative( [
1172			new KeywordMatcher( 'none' ),
1173			Quantifier::hash( UnorderedGroup::allOf( [
1174				Quantifier::count( $matcherFactory->length(), 2, 3 ),
1175				Quantifier::optional( $matcherFactory->color() ),
1176			] ) )
1177		] );
1178
1179		$this->cache[__METHOD__] = $props;
1180		return $props;
1181	}
1182
1183	/**
1184	 * Properties for CSS Box Alignment Module Level 3
1185	 * @see https://www.w3.org/TR/2018/WD-css-align-3-20180423/
1186	 * @param MatcherFactory $matcherFactory Factory for Matchers
1187	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1188	 */
1189	protected function cssAlign3( MatcherFactory $matcherFactory ) {
1190		// @codeCoverageIgnoreStart
1191		if ( isset( $this->cache[__METHOD__] ) ) {
1192			return $this->cache[__METHOD__];
1193		}
1194		// @codeCoverageIgnoreEnd
1195
1196		$props = [];
1197		$normal = new KeywordMatcher( 'normal' );
1198		$normalStretch = new KeywordMatcher( [ 'normal', 'stretch' ] );
1199		$autoNormalStretch = new KeywordMatcher( [ 'auto', 'normal', 'stretch' ] );
1200		$overflowPosition = Quantifier::optional( new KeywordMatcher( [ 'safe', 'unsafe' ] ) );
1201		$baselinePosition = new Juxtaposition( [
1202			Quantifier::optional( new KeywordMatcher( [ 'first', 'last' ] ) ),
1203			new KeywordMatcher( 'baseline' )
1204		] );
1205		$contentDistribution = new KeywordMatcher( [
1206			'space-between', 'space-around', 'space-evenly', 'stretch'
1207		] );
1208		$overflowAndSelfPosition = new Juxtaposition( [
1209			$overflowPosition,
1210			new KeywordMatcher( [
1211				'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end',
1212			] ),
1213		] );
1214		$overflowAndSelfPositionLR = new Juxtaposition( [
1215			$overflowPosition,
1216			new KeywordMatcher( [
1217				'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right',
1218			] ),
1219		] );
1220		$overflowAndContentPos = new Juxtaposition( [
1221			$overflowPosition,
1222			new KeywordMatcher( [ 'center', 'start', 'end', 'flex-start', 'flex-end' ] ),
1223		] );
1224		$overflowAndContentPosLR = new Juxtaposition( [
1225			$overflowPosition,
1226			new KeywordMatcher( [ 'center', 'start', 'end', 'flex-start', 'flex-end', 'left', 'right' ] ),
1227		] );
1228
1229		$props['align-content'] = new Alternative( [
1230			$normal,
1231			$baselinePosition,
1232			$contentDistribution,
1233			$overflowAndContentPos,
1234		] );
1235		$props['justify-content'] = new Alternative( [
1236			$normal,
1237			$contentDistribution,
1238			$overflowAndContentPosLR,
1239		] );
1240		$props['place-content'] = new Juxtaposition( [
1241			$props['align-content'], Quantifier::optional( $props['justify-content'] )
1242		] );
1243		$props['align-self'] = new Alternative( [
1244			$autoNormalStretch,
1245			$baselinePosition,
1246			$overflowAndSelfPosition,
1247		] );
1248		$props['justify-self'] = new Alternative( [
1249			$autoNormalStretch,
1250			$baselinePosition,
1251			$overflowAndSelfPositionLR,
1252		] );
1253		$props['place-self'] = new Juxtaposition( [
1254			$props['align-self'], Quantifier::optional( $props['justify-self'] )
1255		] );
1256		$props['align-items'] = new Alternative( [
1257			$normalStretch,
1258			$baselinePosition,
1259			$overflowAndSelfPosition,
1260		] );
1261		$props['justify-items'] = new Alternative( [
1262			$normalStretch,
1263			$baselinePosition,
1264			$overflowAndSelfPositionLR,
1265			new KeywordMatcher( 'legacy' ),
1266			UnorderedGroup::allOf( [
1267				new KeywordMatcher( 'legacy' ),
1268				new KeywordMatcher( [ 'left', 'right', 'center' ] ),
1269			] ),
1270		] );
1271		$props['place-items'] = new Juxtaposition( [
1272			$props['align-items'], Quantifier::optional( $props['justify-items'] )
1273		] );
1274		$props['row-gap'] = new Alternative( [ $normal, $matcherFactory->lengthPercentage() ] );
1275		$props['column-gap'] = $props['row-gap'];
1276		$props['gap'] = new Juxtaposition( [
1277			$props['row-gap'], Quantifier::optional( $props['column-gap'] )
1278		] );
1279
1280		$this->cache[__METHOD__] = $props;
1281		return $props;
1282	}
1283
1284	/**
1285	 * Properties for CSS Fragmentation Module Level 3
1286	 * @see https://www.w3.org/TR/2017/CR-css-break-3-20170209/
1287	 * @param MatcherFactory $matcherFactory Factory for Matchers
1288	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1289	 */
1290	protected function cssBreak3( MatcherFactory $matcherFactory ) {
1291		// @codeCoverageIgnoreStart
1292		if ( isset( $this->cache[__METHOD__] ) ) {
1293			return $this->cache[__METHOD__];
1294		}
1295		// @codeCoverageIgnoreEnd
1296
1297		$props = [];
1298		$props['break-before'] = new KeywordMatcher( [
1299			'auto', 'avoid', 'avoid-page', 'page', 'left', 'right', 'recto', 'verso', 'avoid-column',
1300			'column', 'avoid-region', 'region'
1301		] );
1302		$props['break-after'] = $props['break-before'];
1303		$props['break-inside'] = new KeywordMatcher( [
1304			'auto', 'avoid', 'avoid-page', 'avoid-column', 'avoid-region'
1305		] );
1306		$props['orphans'] = $matcherFactory->integer();
1307		$props['widows'] = $matcherFactory->integer();
1308		$props['box-decoration-break'] = new KeywordMatcher( [ 'slice', 'clone' ] );
1309		$props['page-break-before'] = new KeywordMatcher( [
1310			'auto', 'always', 'avoid', 'left', 'right'
1311		] );
1312		$props['page-break-after'] = $props['page-break-before'];
1313		$props['page-break-inside'] = new KeywordMatcher( [ 'auto', 'avoid' ] );
1314
1315		$this->cache[__METHOD__] = $props;
1316		return $props;
1317	}
1318
1319	/**
1320	 * Properties for CSS Speech Module
1321	 * @see https://www.w3.org/TR/2012/CR-css3-speech-20120320/
1322	 * @param MatcherFactory $matcherFactory Factory for Matchers
1323	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1324	 */
1325	protected function cssSpeech( MatcherFactory $matcherFactory ) {
1326		// @codeCoverageIgnoreStart
1327		if ( isset( $this->cache[__METHOD__] ) ) {
1328			return $this->cache[__METHOD__];
1329		}
1330		// @codeCoverageIgnoreEnd
1331
1332		$props = [];
1333		$decibel = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1334			return !strcasecmp( $t->unit(), 'dB' );
1335		} );
1336
1337		$props['voice-volume'] = new Alternative( [
1338			new KeywordMatcher( 'silent' ),
1339			UnorderedGroup::someOf( [
1340				new KeywordMatcher( [ 'x-soft', 'soft', 'medium', 'loud', 'x-loud' ] ),
1341				$decibel
1342			] ),
1343		] );
1344		$props['voice-balance'] = new Alternative( [
1345			$matcherFactory->number(),
1346			new KeywordMatcher( [ 'left', 'center', 'right', 'leftwards', 'rightwards' ] ),
1347		] );
1348		$props['speak'] = new KeywordMatcher( [ 'auto', 'none', 'normal' ] );
1349		$props['speak-as'] = new Alternative( [
1350			new KeywordMatcher( 'normal' ),
1351			UnorderedGroup::someOf( [
1352				new KeywordMatcher( 'spell-out' ),
1353				new KeywordMatcher( 'digits' ),
1354				new KeywordMatcher( [ 'literal-punctuation', 'no-punctuation' ] ),
1355			] )
1356		] );
1357		$props['pause-before'] = new Alternative( [
1358			$matcherFactory->time(),
1359			new KeywordMatcher( [ 'none', 'x-weak', 'weak', 'medium', 'strong', 'x-strong' ] ),
1360		] );
1361		$props['pause-after'] = $props['pause-before'];
1362		$props['pause'] = new Juxtaposition( [
1363			$props['pause-before'],
1364			Quantifier::optional( $props['pause-after'] )
1365		] );
1366		$props['rest-before'] = $props['pause-before'];
1367		$props['rest-after'] = $props['pause-after'];
1368		$props['rest'] = $props['pause'];
1369		$props['cue-before'] = new Alternative( [
1370			new Juxtaposition( [ $matcherFactory->url( 'audio' ), Quantifier::optional( $decibel ) ] ),
1371			new KeywordMatcher( 'none' )
1372		] );
1373		$props['cue-after'] = $props['cue-before'];
1374		$props['cue'] = new Juxtaposition( [
1375			$props['cue-before'],
1376			Quantifier::optional( $props['cue-after'] )
1377		] );
1378		$props['voice-family'] = new Alternative( [
1379			Quantifier::hash( new Alternative( [
1380				new Alternative( [ // <name>
1381					$matcherFactory->string(),
1382					Quantifier::plus( $matcherFactory->ident() ),
1383				] ),
1384				new Juxtaposition( [ // <generic-voice>
1385					Quantifier::optional( new KeywordMatcher( [ 'child', 'young', 'old' ] ) ),
1386					new KeywordMatcher( [ 'male', 'female', 'neutral' ] ),
1387					Quantifier::optional( $matcherFactory->integer() ),
1388				] ),
1389			] ) ),
1390			new KeywordMatcher( 'preserve' )
1391		] );
1392		$props['voice-rate'] = UnorderedGroup::someOf( [
1393			new KeywordMatcher( [ 'normal', 'x-slow', 'slow', 'medium', 'fast', 'x-fast' ] ),
1394			$matcherFactory->percentage()
1395		] );
1396		$props['voice-pitch'] = new Alternative( [
1397			UnorderedGroup::allOf( [
1398				new KeywordMatcher( 'absolute' ),
1399				$matcherFactory->frequency(),
1400			] ),
1401			UnorderedGroup::someOf( [
1402				new KeywordMatcher( [ 'x-low', 'low', 'medium', 'high', 'x-high' ] ),
1403				new Alternative( [
1404					$matcherFactory->frequency(),
1405					new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1406						return !strcasecmp( $t->unit(), 'st' );
1407					} ),
1408					$matcherFactory->percentage()
1409				] ),
1410			] ),
1411		] );
1412		$props['voice-range'] = $props['voice-pitch'];
1413		$props['voice-stress'] = new KeywordMatcher( [
1414			'normal', 'strong', 'moderate', 'none', 'reduced'
1415		] );
1416		$props['voice-duration'] = new Alternative( [
1417			new KeywordMatcher( 'auto' ),
1418			$matcherFactory->time()
1419		] );
1420
1421		$this->cache[__METHOD__] = $props;
1422		return $props;
1423	}
1424
1425	/**
1426	 * Properties for CSS Grid Layout Module Level 1
1427	 * @see https://www.w3.org/TR/2017/CR-css-grid-1-20171214/
1428	 * @param MatcherFactory $matcherFactory Factory for Matchers
1429	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1430	 */
1431	protected function cssGrid1( MatcherFactory $matcherFactory ) {
1432		// @codeCoverageIgnoreStart
1433		if ( isset( $this->cache[__METHOD__] ) ) {
1434			return $this->cache[__METHOD__];
1435		}
1436		// @codeCoverageIgnoreEnd
1437
1438		$props = [];
1439		$comma = $matcherFactory->comma();
1440		$slash = new DelimMatcher( '/' );
1441		$customIdent = $matcherFactory->customIdent( [ 'span' ] );
1442		$lineNamesO = Quantifier::optional( new BlockMatcher(
1443			Token::T_LEFT_BRACKET, Quantifier::star( $customIdent )
1444		) );
1445		$trackBreadth = new Alternative( [
1446			$matcherFactory->lengthPercentage(),
1447			new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1448				return $t->value() >= 0 && !strcasecmp( $t->unit(), 'fr' );
1449			} ),
1450			new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
1451		] );
1452		$inflexibleBreadth = new Alternative( [
1453			$matcherFactory->lengthPercentage(),
1454			new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
1455		] );
1456		$fixedBreadth = $matcherFactory->lengthPercentage();
1457		$trackSize = new Alternative( [
1458			$trackBreadth,
1459			new FunctionMatcher( 'minmax',
1460				new Juxtaposition( [ $inflexibleBreadth, $trackBreadth ], true )
1461			),
1462			new FunctionMatcher( 'fit-content', $matcherFactory->lengthPercentage() )
1463		] );
1464		$fixedSize = new Alternative( [
1465			$fixedBreadth,
1466			new FunctionMatcher( 'minmax', new Juxtaposition( [ $fixedBreadth, $trackBreadth ], true ) ),
1467			new FunctionMatcher( 'minmax',
1468				new Juxtaposition( [ $inflexibleBreadth, $fixedBreadth ], true )
1469			),
1470		] );
1471		$trackRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1472			$matcherFactory->integer(),
1473			$comma,
1474			Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
1475			$lineNamesO
1476		] ) );
1477		$autoRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1478			new KeywordMatcher( [ 'auto-fill', 'auto-fit' ] ),
1479			$comma,
1480			Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
1481			$lineNamesO
1482		] ) );
1483		$fixedRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
1484			$matcherFactory->integer(),
1485			$comma,
1486			Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
1487			$lineNamesO
1488		] ) );
1489		$trackList = new Juxtaposition( [
1490			Quantifier::plus( new Juxtaposition( [
1491				$lineNamesO, new Alternative( [ $trackSize, $trackRepeat ] )
1492			] ) ),
1493			$lineNamesO
1494		] );
1495		$autoTrackList = new Juxtaposition( [
1496			Quantifier::star( new Juxtaposition( [
1497				$lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
1498			] ) ),
1499			$lineNamesO,
1500			$autoRepeat,
1501			Quantifier::star( new Juxtaposition( [
1502				$lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
1503			] ) ),
1504			$lineNamesO,
1505		] );
1506		$explicitTrackList = new Juxtaposition( [
1507			Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
1508			$lineNamesO
1509		] );
1510		$autoDense = UnorderedGroup::allOf( [
1511			new KeywordMatcher( 'auto-flow' ),
1512			Quantifier::optional( new KeywordMatcher( 'dense' ) )
1513		] );
1514
1515		$props['grid-template-columns'] = new Alternative( [
1516			new KeywordMatcher( 'none' ), $trackList, $autoTrackList
1517		] );
1518		$props['grid-template-rows'] = $props['grid-template-columns'];
1519		$props['grid-template-areas'] = new Alternative( [
1520			new KeywordMatcher( 'none' ),
1521			Quantifier::plus( $matcherFactory->string() ),
1522		] );
1523		$props['grid-template'] = new Alternative( [
1524			new KeywordMatcher( 'none' ),
1525			new Juxtaposition( [ $props['grid-template-rows'], $slash, $props['grid-template-columns'] ] ),
1526			new Juxtaposition( [
1527				Quantifier::plus( new Juxtaposition( [
1528					$lineNamesO, $matcherFactory->string(), Quantifier::optional( $trackSize ), $lineNamesO
1529				] ) ),
1530				Quantifier::optional( new Juxtaposition( [ $slash, $explicitTrackList ] ) ),
1531			] )
1532		] );
1533		$props['grid-auto-columns'] = Quantifier::plus( $trackSize );
1534		$props['grid-auto-rows'] = $props['grid-auto-columns'];
1535		$props['grid-auto-flow'] = UnorderedGroup::someOf( [
1536			new KeywordMatcher( [ 'row', 'column' ] ),
1537			new KeywordMatcher( 'dense' )
1538		] );
1539		$props['grid'] = new Alternative( [
1540			$props['grid-template'],
1541			new Juxtaposition( [
1542				$props['grid-template-rows'],
1543				$slash,
1544				$autoDense,
1545				Quantifier::optional( $props['grid-auto-columns'] ),
1546			] ),
1547			new Juxtaposition( [
1548				$autoDense,
1549				Quantifier::optional( $props['grid-auto-rows'] ),
1550				$slash,
1551				$props['grid-template-columns'],
1552			] )
1553		] );
1554
1555		$gridLine = new Alternative( [
1556			new KeywordMatcher( 'auto' ),
1557			$customIdent,
1558			UnorderedGroup::allOf( [
1559				$matcherFactory->integer(),
1560				Quantifier::optional( $customIdent )
1561			] ),
1562			UnorderedGroup::allOf( [
1563				new KeywordMatcher( 'span' ),
1564				UnorderedGroup::someOf( [
1565					$matcherFactory->integer(),
1566					$customIdent,
1567				] )
1568			] )
1569		] );
1570		$props['grid-row-start'] = $gridLine;
1571		$props['grid-column-start'] = $gridLine;
1572		$props['grid-row-end'] = $gridLine;
1573		$props['grid-column-end'] = $gridLine;
1574		$props['grid-row'] = new Juxtaposition( [
1575			$gridLine, Quantifier::optional( new Juxtaposition( [ $slash, $gridLine ] ) )
1576		] );
1577		$props['grid-column'] = $props['grid-row'];
1578		$props['grid-area'] = new Juxtaposition( [
1579			$gridLine, Quantifier::count( new Juxtaposition( [ $slash, $gridLine ] ), 0, 3 )
1580		] );
1581
1582		// Replaced by the alignment module
1583		$align = $this->cssAlign3( $matcherFactory );
1584		$props['grid-row-gap'] = $align['row-gap'];
1585		$props['grid-column-gap'] = $align['column-gap'];
1586		$props['grid-gap'] = $align['gap'];
1587
1588		// Also these are copied from the alignment module. Copying is ok as long as
1589		// it's the identical object.
1590		$props['row-gap'] = $align['row-gap'];
1591		$props['column-gap'] = $align['column-gap'];
1592		$props['gap'] = $align['gap'];
1593		$props['justify-self'] = $align['justify-self'];
1594		$props['justify-items'] = $align['justify-items'];
1595		$props['align-self'] = $align['align-self'];
1596		$props['align-items'] = $align['align-items'];
1597		$props['justify-content'] = $align['justify-content'];
1598		$props['align-content'] = $align['align-content'];
1599
1600		// Grid uses Flexbox's order property too. Copying is ok as long as
1601		// it's the identical object.
1602		$props['order'] = $this->cssFlexbox3( $matcherFactory )['order'];
1603
1604		$this->cache[__METHOD__] = $props;
1605		return $props;
1606	}
1607
1608	/**
1609	 * Properties for CSS Filter Effects Module Level 1
1610	 * @see https://www.w3.org/TR/2014/WD-filter-effects-1-20141125/
1611	 * @param MatcherFactory $matcherFactory Factory for Matchers
1612	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1613	 */
1614	protected function cssFilter1( MatcherFactory $matcherFactory ) {
1615		// @codeCoverageIgnoreStart
1616		if ( isset( $this->cache[__METHOD__] ) ) {
1617			return $this->cache[__METHOD__];
1618		}
1619		// @codeCoverageIgnoreEnd
1620
1621		$props = [];
1622
1623		$props['filter'] = new Alternative( [
1624			new KeywordMatcher( 'none' ),
1625			Quantifier::plus( new Alternative( [
1626				new FunctionMatcher( 'blur', $matcherFactory->length() ),
1627				new FunctionMatcher( 'brightness', $matcherFactory->numberPercentage() ),
1628				new FunctionMatcher( 'contrast', $matcherFactory->numberPercentage() ),
1629				new FunctionMatcher( 'drop-shadow', new Juxtaposition( [
1630					Quantifier::count( $matcherFactory->length(), 2, 3 ),
1631					Quantifier::optional( $matcherFactory->color() ),
1632				] ) ),
1633				new FunctionMatcher( 'grayscale', $matcherFactory->numberPercentage() ),
1634				new FunctionMatcher( 'hue-rotate', $matcherFactory->angle() ),
1635				new FunctionMatcher( 'invert', $matcherFactory->numberPercentage() ),
1636				new FunctionMatcher( 'opacity', $matcherFactory->numberPercentage() ),
1637				new FunctionMatcher( 'saturate', $matcherFactory->numberPercentage() ),
1638				new FunctionMatcher( 'sepia', $matcherFactory->numberPercentage() ),
1639				$matcherFactory->url( 'svg' ),
1640			] ) )
1641		] );
1642		$props['flood-color'] = $matcherFactory->color();
1643		$props['flood-opacity'] = $matcherFactory->numberPercentage();
1644		$props['color-interpolation-filters'] = new KeywordMatcher( [ 'auto', 'sRGB', 'linearRGB' ] );
1645		$props['lighting-color'] = $matcherFactory->color();
1646
1647		$this->cache[__METHOD__] = $props;
1648		return $props;
1649	}
1650
1651	/**
1652	 * Shapes and masking share these basic shapes
1653	 * @see https://www.w3.org/TR/2014/CR-css-shapes-1-20140320/#basic-shape-functions
1654	 * @param MatcherFactory $matcherFactory Factory for Matchers
1655	 * @return Matcher
1656	 */
1657	protected function basicShapes( MatcherFactory $matcherFactory ) {
1658		// @codeCoverageIgnoreStart
1659		if ( isset( $this->cache[__METHOD__] ) ) {
1660			return $this->cache[__METHOD__];
1661		}
1662		// @codeCoverageIgnoreEnd
1663
1664		$border = $this->cssBorderBackground3( $matcherFactory );
1665		$sa = $matcherFactory->lengthPercentage();
1666		$sr = new Alternative( [
1667			$sa,
1668			new KeywordMatcher( [ 'closest-side', 'farthest-side' ] ),
1669		] );
1670
1671		$basicShape = new Alternative( [
1672			new FunctionMatcher( 'inset', new Juxtaposition( [
1673				Quantifier::count( $sa, 1, 4 ),
1674				Quantifier::optional( new Juxtaposition( [
1675					new KeywordMatcher( 'round' ), $border['border-radius']
1676				] ) )
1677			] ) ),
1678			new FunctionMatcher( 'circle', new Juxtaposition( [
1679				Quantifier::optional( $sr ),
1680				Quantifier::optional( new Juxtaposition( [
1681					new KeywordMatcher( 'at' ), $matcherFactory->position()
1682				] ) )
1683			] ) ),
1684			new FunctionMatcher( 'ellipse', new Juxtaposition( [
1685				Quantifier::optional( Quantifier::count( $sr, 2, 2 ) ),
1686				Quantifier::optional( new Juxtaposition( [
1687					new KeywordMatcher( 'at' ), $matcherFactory->position()
1688				] ) )
1689			] ) ),
1690			new FunctionMatcher( 'polygon', new Juxtaposition( [
1691				Quantifier::optional( new KeywordMatcher( [ 'nonzero', 'evenodd' ] ) ),
1692				Quantifier::hash( Quantifier::count( $sa, 2, 2 ) ),
1693			], true ) ),
1694		] );
1695
1696		$this->cache[__METHOD__] = $basicShape;
1697		return $basicShape;
1698	}
1699
1700	/**
1701	 * Properties for CSS Shapes Module Level 1
1702	 * @see https://www.w3.org/TR/2014/CR-css-shapes-1-20140320/
1703	 * @param MatcherFactory $matcherFactory Factory for Matchers
1704	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1705	 */
1706	protected function cssShapes1( MatcherFactory $matcherFactory ) {
1707		// @codeCoverageIgnoreStart
1708		if ( isset( $this->cache[__METHOD__] ) ) {
1709			return $this->cache[__METHOD__];
1710		}
1711		// @codeCoverageIgnoreEnd
1712
1713		$shapeBoxKW = $this->backgroundTypes( $matcherFactory )['boxKeywords'];
1714		$shapeBoxKW[] = 'margin-box';
1715
1716		$props = [];
1717
1718		$props['shape-outside'] = new Alternative( [
1719			new KeywordMatcher( 'none' ),
1720			UnorderedGroup::someOf( [
1721				$this->basicShapes( $matcherFactory ),
1722				new KeywordMatcher( $shapeBoxKW ),
1723			] ),
1724			$matcherFactory->url( 'image' ),
1725		] );
1726		$props['shape-image-threshold'] = $matcherFactory->number();
1727		$props['shape-margin'] = $matcherFactory->lengthPercentage();
1728
1729		$this->cache[__METHOD__] = $props;
1730		return $props;
1731	}
1732
1733	/**
1734	 * Properties for CSS Masking Module Level 1
1735	 * @see https://www.w3.org/TR/2014/CR-css-masking-1-20140826/
1736	 * @param MatcherFactory $matcherFactory Factory for Matchers
1737	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1738	 */
1739	protected function cssMasking1( MatcherFactory $matcherFactory ) {
1740		// @codeCoverageIgnoreStart
1741		if ( isset( $this->cache[__METHOD__] ) ) {
1742			return $this->cache[__METHOD__];
1743		}
1744		// @codeCoverageIgnoreEnd
1745
1746		$slash = new DelimMatcher( '/' );
1747		$bgtypes = $this->backgroundTypes( $matcherFactory );
1748		$bg = $this->cssBorderBackground3( $matcherFactory );
1749		$geometryBoxKeywords = array_merge( $bgtypes['boxKeywords'], [
1750			'margin-box', 'fill-box', 'stroke-box', 'view-box'
1751		] );
1752		$geometryBox = new KeywordMatcher( $geometryBoxKeywords );
1753		$maskRef = new Alternative( [
1754			new KeywordMatcher( 'none' ),
1755			$matcherFactory->image(),
1756			$matcherFactory->url( 'svg' ),
1757		] );
1758		$maskMode = new KeywordMatcher( [ 'alpha', 'luminance', 'auto' ] );
1759		$maskClip = new KeywordMatcher( array_merge( $geometryBoxKeywords, [ 'no-clip' ] ) );
1760		$maskComposite = new KeywordMatcher( [ 'add', 'subtract', 'intersect', 'exclude' ] );
1761
1762		$props = [];
1763
1764		$props['clip-path'] = new Alternative( [
1765			$matcherFactory->url( 'svg' ),
1766			UnorderedGroup::someOf( [
1767				$this->basicShapes( $matcherFactory ),
1768				$geometryBox,
1769			] ),
1770			new KeywordMatcher( 'none' ),
1771		] );
1772		$props['clip-rule'] = new KeywordMatcher( [ 'nonzero', 'evenodd' ] );
1773		$props['mask-image'] = Quantifier::hash( $maskRef );
1774		$props['mask-mode'] = Quantifier::hash( $maskMode );
1775		$props['mask-repeat'] = $bg['background-repeat'];
1776		$props['mask-position'] = Quantifier::hash( $matcherFactory->position() );
1777		$props['mask-clip'] = Quantifier::hash( $maskClip );
1778		$props['mask-origin'] = Quantifier::hash( $geometryBox );
1779		$props['mask-size'] = $bg['background-size'];
1780		$props['mask-composite'] = Quantifier::hash( $maskComposite );
1781		$props['mask'] = Quantifier::hash( UnorderedGroup::someOf( [
1782			new Juxtaposition( [ $maskRef, Quantifier::optional( $maskMode ) ] ),
1783			new Juxtaposition( [
1784				$matcherFactory->position(),
1785				Quantifier::optional( new Juxtaposition( [ $slash, $bgtypes['bgsize'] ] ) ),
1786			] ),
1787			$bgtypes['bgrepeat'],
1788			$geometryBox,
1789			$maskClip,
1790			$maskComposite,
1791		] ) );
1792		$props['mask-border-source'] = new Alternative( [
1793			new KeywordMatcher( 'none' ),
1794			$matcherFactory->image(),
1795		] );
1796		$props['mask-border-mode'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
1797		$props['mask-border-slice'] = new Juxtaposition( [ // Different from border-image-slice, sigh
1798			Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
1799			Quantifier::optional( new KeywordMatcher( 'fill' ) ),
1800		] );
1801		$props['mask-border-width'] = $bg['border-image-width'];
1802		$props['mask-border-outset'] = $bg['border-image-outset'];
1803		$props['mask-border-repeat'] = $bg['border-image-repeat'];
1804		$props['mask-border'] = UnorderedGroup::someOf( [
1805			$props['mask-border-source'],
1806			new Juxtaposition( [
1807				$props['mask-border-slice'],
1808				Quantifier::optional( new Juxtaposition( [
1809					$slash,
1810					Quantifier::optional( $props['mask-border-width'] ),
1811					Quantifier::optional( new Juxtaposition( [
1812						$slash,
1813						$props['mask-border-outset'],
1814					] ) ),
1815				] ) ),
1816			] ),
1817			$props['mask-border-repeat'],
1818			$props['mask-border-mode'],
1819		] );
1820		$props['mask-type'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
1821
1822		$this->cache[__METHOD__] = $props;
1823		return $props;
1824	}
1825
1826	/**
1827	 * Additional keywords and functions from CSS Intrinsic and Extrinsic Sizing Level 3
1828	 * @see https://www.w3.org/TR/2018/WD-css-sizing-3-20180304/
1829	 * @param MatcherFactory $matcherFactory Factory for Matchers
1830	 * @return Matcher[] Array of matchers
1831	 */
1832	protected function getSizingAdditions( MatcherFactory $matcherFactory ) {
1833		if ( !isset( $this->cache[__METHOD__] ) ) {
1834			$lengthPct = $matcherFactory->lengthPercentage();
1835			$this->cache[__METHOD__] = [
1836				new KeywordMatcher( [
1837					'max-content', 'min-content',
1838					// Browser-prefixed versions of the keywords, needed by Firefox and iOS Safari as of May 2018
1839					'-moz-max-content', '-moz-min-content',
1840					'-webkit-max-content', '-webkit-min-content',
1841				] ),
1842				new FunctionMatcher( 'fit-content', $lengthPct ),
1843				// Browser-prefixed versions of the function, needed by Firefox and iOS Safari as of May 2018
1844				new FunctionMatcher( '-moz-fit-content', $lengthPct ),
1845				new FunctionMatcher( '-webkit-fit-content', $lengthPct ),
1846			];
1847		}
1848		return $this->cache[__METHOD__];
1849	}
1850
1851	/**
1852	 * Properties for CSS Intrinsic and Extrinsic Sizing Level 3
1853	 * @see https://www.w3.org/TR/2018/WD-css-sizing-3-20180304/
1854	 * @param MatcherFactory $matcherFactory Factory for Matchers
1855	 * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
1856	 */
1857	protected function cssSizing3( MatcherFactory $matcherFactory ) {
1858		// @codeCoverageIgnoreStart
1859		if ( isset( $this->cache[__METHOD__] ) ) {
1860			return $this->cache[__METHOD__];
1861		}
1862		// @codeCoverageIgnoreEnd
1863
1864		$none = new KeywordMatcher( 'none' );
1865		$auto = new KeywordMatcher( 'auto' );
1866		$lengthPct = $matcherFactory->lengthPercentage();
1867		$sizingValues = array_merge( [ $lengthPct ], $this->getSizingAdditions( $matcherFactory ) );
1868
1869		$props = [];
1870		$props['width'] = new Alternative( array_merge( [ $auto ], $sizingValues ) );
1871		$props['min-width'] = $props['width'];
1872		$props['max-width'] = new Alternative( array_merge( [ $none ], $sizingValues ) );
1873		$props['height'] = $props['width'];
1874		$props['min-height'] = $props['min-width'];
1875		$props['max-height'] = $props['max-width'];
1876
1877		// Copying is ok as long as it's the identical object.
1878		$props['box-sizing'] = $this->cssUI4( $matcherFactory )['box-sizing'];
1879
1880		$this->cache[__METHOD__] = $props;
1881		return $props;
1882	}
1883}
1884