diff --git a/customcss.php b/customcss.php
index 8e0bfc76f..96ab9afd8 100644
--- a/customcss.php
+++ b/customcss.php
@@ -2,6 +2,13 @@
include '../pokemonshowdown.com/config/servers.inc.php';
+spl_autoload_register(function ($class) {
+ require_once('lib/css-sanitizer/'.$class.'.php');
+});
+
+use Wikimedia\CSS\Parser\Parser;
+use Wikimedia\CSS\Sanitizer\StylesheetSanitizer;
+
$server = @$_REQUEST['server'];
if ($server === 'showdown' || $server === 'smogtours') die();
if (empty($PokemonServers[$server])) {
@@ -57,25 +64,16 @@ if ($curlret) {
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($code === 200) {
// Sanitise the CSS.
- require '../pokemonshowdown.com/lib/htmlpurifier/HTMLPurifier.auto.php';
- require '../pokemonshowdown.com/lib/csstidy/class.csstidy.php';
-
- $config = HTMLPurifier_Config::createDefault();
-
- $config->set('Filter.ExtractStyleBlocks', true);
- $config->set('CSS.Proprietary', true);
- $config->set('CSS.AllowImportant', true);
- $config->set('CSS.AllowTricky', true);
- $level = error_reporting(E_ALL & ~E_STRICT);
-
- // $purifier = new HTMLPurifier($config);
- // $html = $purifier->purify('');
- // error_reporting($level);
- // list($outputcss) = $purifier->context->get('StyleBlocks');
-
- $context = new HTMLPurifier_Context();
- $filter = new HTMLPurifier_Filter_ExtractStyleBlocks();
- $outputcss = $filter->cleanCSS($curlret, $config, $context);
+ // Parse a stylesheet from a string
+ $parser = Parser::newFromString($curlret);
+ $stylesheet = $parser->parseStylesheet();
+
+ // Apply sanitization to the stylehseet
+ $sanitizer = StylesheetSanitizer::newDefault();
+ $newStylesheet = $sanitizer->sanitize( $stylesheet );
+
+ // Convert the sanitized stylesheet back to text
+ $outputcss = Wikimedia\CSS\Util::stringify( $newStylesheet, [ 'minify' => true ] );
file_put_contents($cssfile, $outputcss);
if (!$invalidate) echo $outputcss;
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/Alternative.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Alternative.php
new file mode 100644
index 000000000..bb683d3cd
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Alternative.php
@@ -0,0 +1,41 @@
+matchers = $matchers;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $used = [];
+ foreach ( $this->matchers as $matcher ) {
+ foreach ( $matcher->generateMatches( $values, $start, $options ) as $match ) {
+ $newMatch = $this->makeMatch( $values, $start, $match->getNext(), $match );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/AnythingMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/AnythingMatcher.php
new file mode 100644
index 000000000..1e074a121
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/AnythingMatcher.php
@@ -0,0 +1,137 @@
+` instead of ``)
+ * - quantifier: (string) Set to '*' or '+' to work like `*` or
+ * `+` but without backtracking. Note this will probably fail to
+ * match correctly if anything else is supposed to come after the
+ * AnythingMatcher, i.e. only use this where there's nothing else to the
+ * end of the input.
+ * @note To properly match the draft's `` or
+ * ``, specify '+' for the 'quantifier' option.
+ */
+ public function __construct( array $options = [] ) {
+ $this->toplevel = !empty( $options['toplevel'] );
+ $this->quantifier = isset( $options['quantifier'] ) ? $options['quantifier'] : '';
+ if ( !in_array( $this->quantifier, [ '', '+', '*' ], true ) ) {
+ throw new \InvalidArgumentException( 'Invalid quantifier' );
+ }
+
+ $recurse = !$this->toplevel && $this->quantifier === '*'
+ ? $this : new static( [ 'quantifier' => '*' ] );
+ $this->matchers[Token::T_FUNCTION] = new FunctionMatcher( null, $recurse );
+ foreach ( [ Token::T_LEFT_PAREN, Token::T_LEFT_BRACE, Token::T_LEFT_BRACKET ] as $delim ) {
+ $this->matchers[$delim] = new BlockMatcher( $delim, $recurse );
+ }
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $origStart = $start;
+ $lastMatch = $this->quantifier === '*' ? $this->makeMatch( $values, $start, $start ) : null;
+ do {
+ $newMatch = null;
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof Token ) {
+ switch ( $cv->type() ) {
+ case Token::T_BAD_STRING:
+ case Token::T_BAD_URL:
+ case Token::T_RIGHT_PAREN:
+ case Token::T_RIGHT_BRACE:
+ case Token::T_RIGHT_BRACKET:
+ case Token::T_EOF:
+ // Not allowed
+ break;
+
+ case Token::T_SEMICOLON:
+ if ( !$this->toplevel ) {
+ $newMatch = $this->makeMatch(
+ $values, $origStart, $this->next( $values, $start, $options ), $lastMatch
+ );
+ }
+ break;
+
+ case Token::T_DELIM:
+ if ( !$this->toplevel || $cv->value() !== '!' ) {
+ $newMatch = $this->makeMatch(
+ $values, $origStart, $this->next( $values, $start, $options ), $lastMatch
+ );
+ }
+ break;
+
+ case Token::T_WHITESPACE:
+ // If we encounter whitespace, assume it's significant.
+ $newMatch = $this->makeMatch(
+ $values, $origStart, $this->next( $values, $start, $options ),
+ new Match( $values, $start, 1, 'significantWhitespace' ),
+ [ [ $lastMatch ] ]
+ );
+ break;
+
+ case Token::T_FUNCTION:
+ case Token::T_LEFT_PAREN:
+ case Token::T_LEFT_BRACE:
+ case Token::T_LEFT_BRACKET:
+ // Should never happen
+ // @codeCoverageIgnoreStart
+ throw new \UnexpectedValueException( "How did a \"{$cv->type()}\" token get here?" );
+ // @codeCoverageIgnoreEnd
+
+ default:
+ $newMatch = $this->makeMatch(
+ $values, $origStart, $this->next( $values, $start, $options ), $lastMatch
+ );
+ break;
+ }
+ } elseif ( $cv instanceof CSSFunction || $cv instanceof SimpleBlock ) {
+ $tok = $cv instanceof SimpleBlock ? $cv->getStartTokenType() : Token::T_FUNCTION;
+ // We know there's only one way for the submatcher to match, so just grab the first one
+ $match = $this->matchers[$tok]
+ ->generateMatches( new ComponentValueList( [ $cv ] ), 0, $options )
+ ->current();
+ if ( $match ) {
+ $newMatch = $this->makeMatch(
+ $values, $origStart, $this->next( $values, $start, $options ), $match, [ [ $lastMatch ] ]
+ );
+ }
+ }
+ if ( $newMatch ) {
+ $lastMatch = $newMatch;
+ $start = $newMatch->getNext();
+ }
+ } while ( $this->quantifier !== '' && $newMatch );
+
+ if ( $lastMatch ) {
+ yield $lastMatch;
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/BlockMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/BlockMatcher.php
new file mode 100644
index 000000000..f217057bb
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/BlockMatcher.php
@@ -0,0 +1,62 @@
+ ']'`.
+ */
+class BlockMatcher extends Matcher {
+ /** @var string One of the Token::T_* constants */
+ protected $blockType;
+
+ /** @var Matcher */
+ protected $matcher;
+
+ /**
+ * @param string $blockType One of the Token::T_* constants
+ * @param Matcher $matcher Matcher for the contents of the block
+ */
+ public function __construct( $blockType, Matcher $matcher ) {
+ if ( SimpleBlock::matchingDelimiter( $blockType ) === null ) {
+ throw new \InvalidArgumentException(
+ 'A block is delimited by either {}, [], or ().'
+ );
+ }
+ $this->blockType = $blockType;
+ $this->matcher = $matcher;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof SimpleBlock && $cv->getStartTokenType() === $this->blockType ) {
+ // To successfully match, our sub-Matcher needs to match the whole
+ // content of the block.
+ $l = $cv->getValue()->count();
+ $s = $this->next( $cv->getValue(), -1, $options );
+ foreach ( $this->matcher->generateMatches( $cv->getValue(), $s, $options ) as $match ) {
+ if ( $match->getNext() === $l ) {
+ // Matched the whole content of the block, so yield the
+ // token after the block.
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ), $match );
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/CheckedMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/CheckedMatcher.php
new file mode 100644
index 000000000..2ab084c66
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/CheckedMatcher.php
@@ -0,0 +1,38 @@
+matcher = $matcher;
+ $this->check = $check;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ foreach ( $this->matcher->generateMatches( $values, $start, $options ) as $match ) {
+ if ( call_user_func( $this->check, $values, $match, $options ) ) {
+ yield $this->makeMatch( $values, $start, $match->getNext(), $match );
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/DelimMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/DelimMatcher.php
new file mode 100644
index 000000000..11061c86d
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/DelimMatcher.php
@@ -0,0 +1,50 @@
+s, but will work for
+ * other types (case-sensitively) too. For the more common case-insensitive
+ * identifier matching, use KeywordMatcher.
+ *
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#component-types
+ */
+class DelimMatcher extends Matcher {
+ /** @var string One of the Token::T_* constants */
+ protected $type;
+
+ /** @var string[] Values to match */
+ protected $values;
+
+ /**
+ * @param string|string[] $values Token values to match
+ * @param array $options Options
+ * - type: (string) Token type to match. Default is Token::T_DELIM.
+ */
+ public function __construct( $values, array $options = [] ) {
+ $options += [
+ 'type' => Token::T_DELIM,
+ ];
+
+ $this->values = array_map( 'strval', (array)$values );
+ $this->type = $options['type'];
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof Token && $cv->type() === $this->type &&
+ in_array( $cv->value(), $this->values, true )
+ ) {
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ) );
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/FunctionMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/FunctionMatcher.php
new file mode 100644
index 000000000..7269ce337
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/FunctionMatcher.php
@@ -0,0 +1,68 @@
+ ')'`.
+ */
+class FunctionMatcher extends Matcher {
+ /** @var callable|null Function name */
+ protected $nameCheck;
+
+ /** @var Matcher */
+ protected $matcher;
+
+ /**
+ * @param string|callable|null $name Function name, case-insensitive, or a
+ * function to check the name.
+ * @param Matcher $matcher Matcher for the contents of the function
+ */
+ public function __construct( $name, Matcher $matcher ) {
+ if ( is_string( $name ) ) {
+ $this->nameCheck = function ( $s ) use ( $name ) {
+ return !strcasecmp( $s, $name );
+ };
+ } elseif ( is_callable( $name ) || $name === null ) {
+ $this->nameCheck = $name;
+ } else {
+ throw new \InvalidArgumentException( '$name must be a string, callable, or null' );
+ }
+ $this->matcher = $matcher;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof CSSFunction &&
+ ( !$this->nameCheck || call_user_func( $this->nameCheck, $cv->getName() ) )
+ ) {
+ // To successfully match, our sub-Matcher needs to match the whole
+ // content of the function.
+ $l = $cv->getValue()->count();
+ $s = $this->next( $cv->getValue(), -1, $options );
+ foreach ( $this->matcher->generateMatches( $cv->getValue(), $s, $options ) as $match ) {
+ if ( $match->getNext() === $l ) {
+ // Matched the whole content of the function, so yield the
+ // token after the function.
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ), $match );
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/Juxtaposition.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Juxtaposition.php
new file mode 100644
index 000000000..2f851985b
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Juxtaposition.php
@@ -0,0 +1,117 @@
+matchers = $matchers;
+ $this->commas = (bool)$commas;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $used = [];
+
+ // Match each of our matchers in turn, pushing each one onto a stack as
+ // we process it and popping a match once its exhausted.
+ $stack = [
+ [
+ new Match( $values, $start, 0 ),
+ $start,
+ $this->matchers[0]->generateMatches( $values, $start, $options ),
+ false
+ ]
+ ];
+ do {
+ /** @var $lastMatch Match */
+ /** @var $lastEnd int */
+ /** @var $iter \Iterator */
+ /** @var $needEmpty bool */
+ list( $lastMatch, $lastEnd, $iter, $needEmpty ) = $stack[count( $stack ) - 1];
+
+ // If the top of the stack has no more matches, pop it and loop.
+ if ( !$iter->valid() ) {
+ array_pop( $stack );
+ continue;
+ }
+
+ // Find the next match for the current top of the stack.
+ $match = $iter->current();
+ $iter->next();
+
+ // In some cases, we can only match if the rest of the pattern
+ // is empty. If we're in that situation, ignore all non-empty
+ // matches.
+ if ( $needEmpty && $match->getLength() !== 0 ) {
+ continue;
+ }
+
+ $thisEnd = $nextFrom = $match->getNext();
+
+ // Dealing with commas is a bit tricky. There are three cases:
+ // 1. If the current match is empty, don't look for a following
+ // comma now and reset $thisEnd to $lastEnd.
+ // 2. If there is a comma following, update $nextFrom to be after
+ // the comma.
+ // 3. If there's no comma following, every subsequent Matcher must
+ // be empty in order for the group as a whole to match, so set
+ // the flag.
+ // Unlike '#', this doesn't specify skipping whitespace around the
+ // commas if the production isn't already skipping whitespace.
+ if ( $this->commas ) {
+ if ( $match->getLength() === 0 ) {
+ $thisEnd = $lastEnd;
+ } else {
+ if ( isset( $values[$nextFrom] ) && $values[$nextFrom] instanceof Token &&
+ $values[$nextFrom]->type() === Token::T_COMMA
+ ) {
+ $nextFrom = $this->next( $values, $nextFrom, $options );
+ } else {
+ $needEmpty = true;
+ }
+ }
+ }
+
+ // If we ran out of Matchers, yield the final position. Otherwise
+ // push the next matcher onto the stack.
+ if ( count( $stack ) >= count( $this->matchers ) ) {
+ $newMatch = $this->makeMatch( $values, $start, $thisEnd, $match, $stack );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ } else {
+ $stack[] = [
+ $match,
+ $thisEnd,
+ $this->matchers[count( $stack )]->generateMatches( $values, $nextFrom, $options ),
+ $needEmpty
+ ];
+ }
+ } while ( $stack );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/KeywordMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/KeywordMatcher.php
new file mode 100644
index 000000000..5854765f4
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/KeywordMatcher.php
@@ -0,0 +1,50 @@
+s, but will work for
+ * other types (case-insensitively) too. For delimiter (or case-sensitive)
+ * matching, use DelimMatcher.
+ *
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#component-types
+ */
+class KeywordMatcher extends Matcher {
+ /** @var string One of the Token::T_* constants */
+ protected $type;
+
+ /** @var array Associative array with keys being the values to match */
+ protected $values;
+
+ /**
+ * @param string|string[] $values Token values to match
+ * @param array $options Options
+ * - type: (string) Token type to match. Default is Token::T_IDENT.
+ */
+ public function __construct( $values, array $options = [] ) {
+ $options += [
+ 'type' => Token::T_IDENT,
+ ];
+
+ $this->values = array_flip( array_map( 'strtolower', (array)$values ) );
+ $this->type = $options['type'];
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof Token && $cv->type() === $this->type &&
+ isset( $this->values[strtolower( $cv->value() )] )
+ ) {
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ) );
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/Match.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Match.php
new file mode 100644
index 000000000..93e64a99b
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Match.php
@@ -0,0 +1,146 @@
+values = $list->slice( $start, $length );
+ $this->start = $start;
+ $this->length = $length;
+ $this->name = $name;
+ $this->capturedMatches = $capturedMatches;
+ }
+
+ /**
+ * The matched values
+ * @return ComponentValue[]
+ */
+ public function getValues() {
+ return $this->values;
+ }
+
+ /**
+ * The starting position of this match within the original list of values.
+ * @return int
+ */
+ public function getStart() {
+ return $this->start;
+ }
+
+ /**
+ * The length of this match
+ * @return int
+ */
+ public function getLength() {
+ return $this->length;
+ }
+
+ /**
+ * The position after this match, as in index into the original list of values.
+ * @return int
+ */
+ public function getNext() {
+ return $this->start + $this->length;
+ }
+
+ /**
+ * The name of this match
+ * @return string|null
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * The captured submatches of this match
+ *
+ * This returns the matches from capturing submatchers (see
+ * Matcher::capture()) that matched during the matching of the top-level
+ * matcher that returned this match. If capturing submatchers were nested,
+ * the Match objects returned here will themselves have captured submatches to
+ * return.
+ *
+ * To borrow PCRE regular expression syntax, if the "pattern" described by
+ * the Matchers resembled `www(?xxx(?yyy)xxx)(?zzz)*` then the
+ * top-level Match's getCapturedMatches() would return a Match named "A"
+ * (containing the "xxxyyyxxx" bit) and zero or more matches named "C" (for
+ * each "zzz"), and that "A" Match's getCapturedMatches() would return a Match
+ * named "B" (containing just the "yyy").
+ *
+ * Note that the start and end positions reported by captured matches may be
+ * relative to a containing SimpleBlock or CSSFunction's value rather than
+ * to the ComponentValueList passed to the top-level Matcher.
+ *
+ * @return Match[]
+ */
+ public function getCapturedMatches() {
+ return $this->capturedMatches;
+ }
+
+ /**
+ * Return a key for this matcher's state
+ * @return string
+ */
+ public function getUniqueID() {
+ $data = [ $this->start, $this->length, $this->name ];
+ foreach ( $this->capturedMatches as $m ) {
+ $data[] = $m->getUniqueId();
+ }
+ return md5( join( "\n", $data ) );
+ }
+
+ /**
+ * Replace whitespace in the matched values
+ * @private For use by Matcher only
+ * @param Token $old
+ * @param Token $new
+ */
+ public function fixWhitespace( Token $old, Token $new ) {
+ foreach ( $this->values as $k => $v ) {
+ if ( $v === $old ) {
+ $this->values[$k] = $new;
+ }
+ }
+ foreach ( $this->capturedMatches as $m ) {
+ $m->fixWhitespace( $old, $new );
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/Matcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Matcher.php
new file mode 100644
index 000000000..c314aa8fd
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/Matcher.php
@@ -0,0 +1,257 @@
+ true,
+ 'nonterminal' => false,
+ 'mark-significance' => false,
+ ];
+
+ /**
+ * Create an instance.
+ * @param mixed ... See static::__construct()
+ * @return static
+ */
+ public static function create() {
+ // @todo Once we drop support for PHP 5.5, just do this:
+ // public static function create( ...$args ) {
+ // return new static( ...$args );
+ // }
+
+ $args = func_get_args();
+ switch ( count( $args ) ) {
+ case 0:
+ return new static();
+ case 1:
+ return new static( $args[0] );
+ case 2:
+ return new static( $args[0], $args[1] );
+ case 3:
+ return new static( $args[0], $args[1], $args[2] );
+ case 4:
+ return new static( $args[0], $args[1], $args[2], $args[3] );
+ default:
+ // Slow, but all the existing Matchers have a max of 4 args.
+ $rc = new \ReflectionClass( static::class );
+ return $rc->newInstanceArgs( $args );
+ }
+ }
+
+ /**
+ * Return a copy of this matcher that will capture its matches
+ *
+ * A "capturing" Matcher will produce Matches that return a value from the
+ * Match::getName() method. The Match::getCapturedMatches() method may be
+ * used to retrieve them from the top-level Match.
+ *
+ * The concept is similar to capturing groups in PCRE and other regex
+ * languages.
+ *
+ * @param string|null $captureName Name to apply to captured Match objects
+ * @return static
+ */
+ public function capture( $captureName ) {
+ $ret = clone( $this );
+ $ret->captureName = $captureName;
+ return $ret;
+ }
+
+ /**
+ * Match against a list of ComponentValues
+ * @param ComponentValueList $values
+ * @param array $options Matching options, see self::$defaultOptions
+ * @return Match|null
+ */
+ public function match( ComponentValueList $values, array $options = [] ) {
+ $options += $this->getDefaultOptions();
+ $start = $this->next( $values, -1, $options );
+ $l = count( $values );
+ foreach ( $this->generateMatches( $values, $start, $options ) as $match ) {
+ if ( $match->getNext() === $l || $options['nonterminal'] ) {
+ if ( $options['mark-significance'] ) {
+ $significantWS = self::collectSignificantWhitespace( $match );
+ self::markSignificantWhitespace( $values, $match, $significantWS, $match->getNext() );
+ }
+ return $match;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Collect any 'significantWhitespace' matches
+ * @param Match $match
+ * @param Token[]|null &$ret
+ * @return Token[]
+ */
+ private static function collectSignificantWhitespace( Match $match, &$ret = [] ) {
+ if ( $match->getName() === 'significantWhitespace' ) {
+ $ret = array_merge( $ret, $match->getValues() );
+ }
+ foreach ( $match->getCapturedMatches() as $m ) {
+ self::collectSignificantWhitespace( $m, $ret );
+ }
+ return $ret;
+ }
+
+ /**
+ * Mark whitespace as significant or not
+ * @param ComponentValueList $list
+ * @param Match $match
+ * @param Token[] $significantWS
+ * @param int $end
+ */
+ private static function markSignificantWhitespace( $list, $match, $significantWS, $end ) {
+ for ( $i = 0; $i < $end; $i++ ) {
+ $cv = $list[$i];
+ if ( $cv instanceof Token && $cv->type() === Token::T_WHITESPACE ) {
+ $significant = in_array( $cv, $significantWS, true );
+ if ( $significant !== $cv->significant() ) {
+ $list[$i] = $cv->copyWithSignificance( $significant );
+ $match->fixWhitespace( $cv, $list[$i] );
+ }
+ } elseif ( $cv instanceof CSSFunction || $cv instanceof SimpleBlock ) {
+ self::markSignificantWhitespace(
+ $cv->getValue(), $match, $significantWS, count( $cv->getValue() )
+ );
+ }
+ }
+ }
+
+ /**
+ * Fetch the default options for this Matcher
+ * @return array See self::$defaultOptions
+ */
+ public function getDefaultOptions() {
+ return $this->defaultOptions;
+ }
+
+ /**
+ * Set the default options for this Matcher
+ * @param array $options See self::$defaultOptions
+ * @return static $this
+ */
+ public function setDefaultOptions( array $options ) {
+ $this->defaultOptions = $options + $this->defaultOptions;
+ return $this;
+ }
+
+ /**
+ * Find the next ComponentValue in the input, possibly skipping whitespace
+ * @param ComponentValueList $values Input values
+ * @param int $start Current position in the input. May be -1, in which
+ * case the first position in the input should be returned.
+ * @param array $options See self::$defaultOptions
+ * @return int Next token index
+ */
+ protected function next( ComponentValueList $values, $start, array $options ) {
+ $skipWS = $options['skip-whitespace'];
+
+ $i = $start;
+ $l = count( $values );
+ do {
+ $i++;
+ } while ( $skipWS && $i < $l &&
+ $values[$i] instanceof Token && $values[$i]->type() === Token::T_WHITESPACE
+ );
+ return $i;
+ }
+
+ /**
+ * Create a Match
+ * @param ComponentValueList $list
+ * @param int $start
+ * @param int $end First position after the match
+ * @param Match|null $submatch Submatch, for capturing. If $submatch itself
+ * named it will be kept as a capture in the returned Match, otherwise its
+ * captured matches (if any) as returned by getCapturedMatches() will be
+ * kept as captures in the returned Match.
+ * @param array $stack Stack from which to fetch more submatches for
+ * capturing (see $submatch). The stack is expected to be an array of
+ * arrays, with the first element of each subarray being a Match.
+ * @return Match
+ */
+ protected function makeMatch(
+ ComponentValueList $list, $start, $end, Match $submatch = null, array $stack = []
+ ) {
+ $matches = array_column( $stack, 0 );
+ $matches[] = $submatch;
+
+ $keptMatches = [];
+ while ( $matches ) {
+ $m = array_shift( $matches );
+ if ( !$m instanceof Match ) {
+ // skip it, probably null
+ } elseif ( $m->getName() !== null ) {
+ $keptMatches[] = $m;
+ } elseif ( $m->getCapturedMatches() ) {
+ $matches = array_merge( $m->getCapturedMatches(), $matches );
+ }
+ }
+
+ return new Match( $list, $start, $end - $start, $this->captureName, $keptMatches );
+ }
+
+ /**
+ * Match against a list of ComponentValues
+ *
+ * The job of a Matcher is to determine all the ways its particular grammar
+ * fragment can consume ComponentValues starting at a particular location
+ * in the ComponentValueList, represented by returning Match objects. For
+ * example, a matcher implementing `IDENT*` at a starting position where
+ * there are three IDENT tokens in a row would be able to match 0, 1, 2, or
+ * all 3 of those IDENT tokens, and therefore should return an iterator
+ * over that set of Match objects.
+ *
+ * Some matchers take other matchers as input, for example `IDENT*` is
+ * probably going to be implemented as a matcher for `*` that repeatedly
+ * applies a matcher for `IDENT`. The `*` matcher would call the `IDENT`
+ * matcher's generateMatches() method directly.
+ *
+ * Most Matchers implement this method as a generator so as to not build up
+ * the full set of results when it's reasonably likely the caller is going
+ * to terminate early.
+ *
+ * @param ComponentValueList $values
+ * @param int $start Starting position in $values
+ * @param array $options See self::$defaultOptions.
+ * Always use the options passed in, don't use $this->defaultOptions yourself.
+ * @return \Iterator Iterates over the set of Match objects
+ * defining all the ways this matcher can match.
+ */
+ abstract protected function generateMatches( ComponentValueList $values, $start, array $options );
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/MatcherFactory.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/MatcherFactory.php
new file mode 100644
index 000000000..d4a1e5851
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/MatcherFactory.php
@@ -0,0 +1,1282 @@
+cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for required whitespace
+ * @return Matcher
+ */
+ public function significantWhitespace() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => true ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a comma
+ * @return Matcher
+ */
+ public function comma() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_COMMA );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for an arbitrary identifier
+ * @return Matcher
+ */
+ public function ident() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_IDENT );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a string
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#strings
+ * @warning If the string will be used as a URL, use self::urlstring() instead.
+ * @return Matcher
+ */
+ public function string() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_STRING );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a string containing a URL
+ * @param string $type Type of resource referenced, e.g. "image" or "audio".
+ * Not used here, but might be used by a subclass to validate the URL more strictly.
+ * @return Matcher
+ */
+ public function urlstring( $type ) {
+ return $this->string();
+ }
+
+ /**
+ * Matcher for a URL
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#urls
+ * @param string $type Type of resource referenced, e.g. "image" or "audio".
+ * Not used here, but might be used by a subclass to validate the URL more strictly.
+ * @return Matcher
+ */
+ public function url( $type ) {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new UrlMatcher();
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * CSS-wide value keywords
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#common-keywords
+ * @return Matcher
+ */
+ public function cssWideKeywords() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new KeywordMatcher( [ 'initial', 'inherit', 'unset' ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Add calc() support to a basic type matcher
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#calc-notation
+ * @param Matcher $typeMatcher Matcher for the type
+ * @param string $type Type being matched
+ * @return Matcher
+ */
+ public function calc( Matcher $typeMatcher, $type ) {
+ if ( $type === 'integer' ) {
+ $num = $this->rawInteger();
+ } else {
+ $num = $this->rawNumber();
+ }
+
+ $ows = $this->optionalWhitespace();
+ $ws = $this->significantWhitespace();
+
+ // Definitions are recursive. This will be used by reference and later
+ // will be replaced.
+ $calcValue = new NothingMatcher();
+
+ if ( $type === 'integer' ) {
+ // Division will always resolve to a number, making the expression
+ // invalid, so don't allow it.
+ $calcProduct = new Juxtaposition( [
+ &$calcValue,
+ Quantifier::star( new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ) )
+ ] );
+ } else {
+ $calcProduct = new Juxtaposition( [
+ &$calcValue,
+ Quantifier::star( new Alternative( [
+ new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ),
+ new Juxtaposition( [ $ows, new DelimMatcher( '/' ), $ows, $this->rawNumber() ] ),
+ ] ) ),
+ ] );
+ }
+
+ $calcSum = new Juxtaposition( [
+ $ows,
+ $calcProduct,
+ Quantifier::star( new Juxtaposition( [
+ $ws, new DelimMatcher( [ '+', '-' ] ), $ws, $calcProduct
+ ] ) ),
+ $ows,
+ ] );
+
+ $calcFunc = new FunctionMatcher( 'calc', $calcSum );
+
+ if ( $num === $typeMatcher ) {
+ $calcValue = new Alternative( [
+ $typeMatcher,
+ new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
+ $calcFunc,
+ ] );
+ } else {
+ $calcValue = new Alternative( [
+ $num,
+ $typeMatcher,
+ new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
+ $calcFunc,
+ ] );
+ }
+
+ return new Alternative( [ $typeMatcher, $calcFunc ] );
+ }
+
+ /**
+ * Matcher for an integer value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
+ * @return Matcher
+ */
+ protected function rawInteger() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ // The spec says it must match /^[+-]\d+$/, but the tokenizer
+ // should have marked any other number token as a 'number'
+ // anyway so let's not bother checking.
+ return $t->typeFlag() === 'integer';
+ } );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for an integer value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
+ * @return Matcher
+ */
+ public function integer() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawInteger(), 'integer' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a real number, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
+ * @return Matcher
+ */
+ public function rawNumber() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a real number
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
+ * @return Matcher
+ */
+ public function number() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawNumber(), 'number' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a percentage value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
+ * @return Matcher
+ */
+ public function rawPercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_PERCENTAGE );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
+ * @return Matcher
+ */
+ public function percentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawPercentage(), 'percentage' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a length-percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-length-percentage
+ * @return Matcher
+ */
+ public function lengthPercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc(
+ new Alternative( [ $this->rawLength(), $this->rawPercentage() ] ),
+ 'length'
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a frequency-percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-frequency-percentage
+ * @return Matcher
+ */
+ public function frequencyPercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc(
+ new Alternative( [ $this->rawFrequency(), $this->rawPercentage() ] ),
+ 'frequency'
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a angle-percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-angle-percentage
+ * @return Matcher
+ */
+ public function anglePercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc(
+ new Alternative( [ $this->rawAngle(), $this->rawPercentage() ] ),
+ 'angle'
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a time-percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-time-percentage
+ * @return Matcher
+ */
+ public function timePercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc(
+ new Alternative( [ $this->rawTime(), $this->rawPercentage() ] ),
+ 'time'
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a number-percentage value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-number-percentage
+ * @return Matcher
+ */
+ public function numberPercentage() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc(
+ new Alternative( [ $this->rawNumber(), $this->rawPercentage() ] ),
+ 'number'
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a dimension value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#dimensions
+ * @return Matcher
+ */
+ public function dimension() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matches the number 0
+ * @return Matcher
+ */
+ protected function zero() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->value() === 0 || $t->value() === 0.0;
+ } );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a length value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
+ * @return Matcher
+ */
+ protected function rawLength() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $unitsRe = '/^(' . join( '|', self::$lengthUnits ) . ')$/i';
+
+ $this->cache[__METHOD__] = new Alternative( [
+ $this->zero(),
+ new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
+ return preg_match( $unitsRe, $t->unit() );
+ } ),
+ ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a length value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
+ * @return Matcher
+ */
+ public function length() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawLength(), 'length' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for an angle value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
+ * @return Matcher
+ */
+ protected function rawAngle() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $unitsRe = '/^(' . join( '|', self::$angleUnits ) . ')$/i';
+
+ $this->cache[__METHOD__] = new Alternative( [
+ $this->zero(),
+ new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
+ return preg_match( $unitsRe, $t->unit() );
+ } ),
+ ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for an angle value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
+ * @return Matcher
+ */
+ public function angle() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawAngle(), 'angle' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a duration (time) value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
+ * @return Matcher
+ */
+ protected function rawTime() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $unitsRe = '/^(' . join( '|', self::$timeUnits ) . ')$/i';
+
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
+ function ( Token $t ) use ( $unitsRe ) {
+ return preg_match( $unitsRe, $t->unit() );
+ }
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a duration (time) value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
+ * @return Matcher
+ */
+ public function time() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawTime(), 'time' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a frequency value, without calc()
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
+ * @return Matcher
+ */
+ protected function rawFrequency() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $unitsRe = '/^(' . join( '|', self::$frequencyUnits ) . ')$/i';
+
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
+ function ( Token $t ) use ( $unitsRe ) {
+ return preg_match( $unitsRe, $t->unit() );
+ }
+ );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a frequency value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
+ * @return Matcher
+ */
+ public function frequency() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = $this->calc( $this->rawFrequency(), 'frequency' );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a resolution value
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#resolution
+ * @return Matcher
+ */
+ public function resolution() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return preg_match( '/^(dpi|dpcm|dppx)$/i', $t->unit() );
+ } );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matchers for color functions
+ * @return Matcher[]
+ */
+ protected function colorFuncs() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $i = $this->integer();
+ $n = $this->number();
+ $p = $this->percentage();
+ $this->cache[__METHOD__] = [
+ new FunctionMatcher( 'rgb', new Alternative( [
+ Quantifier::hash( $i, 3, 3 ),
+ Quantifier::hash( $p, 3, 3 ),
+ ] ) ),
+ new FunctionMatcher( 'rgba', new Alternative( [
+ new Juxtaposition( [ $i, $i, $i, $n ], true ),
+ new Juxtaposition( [ $p, $p, $p, $n ], true ),
+ ] ) ),
+ new FunctionMatcher( 'hsl', new Juxtaposition( [ $n, $p, $p ], true ) ),
+ new FunctionMatcher( 'hsla', new Juxtaposition( [ $n, $p, $p, $n ], true ) ),
+ ];
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a color value
+ * @see https://www.w3.org/TR/2011/REC-css3-color-20110607/#colorunits
+ * @return Matcher
+ */
+ public function color() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Alternative( array_merge( [
+ new KeywordMatcher( [
+ // Basic colors
+ 'aqua', 'black', 'blue', 'fuchsia', 'gray', 'green',
+ 'lime', 'maroon', 'navy', 'olive', 'purple', 'red',
+ 'silver', 'teal', 'white', 'yellow',
+ // Extended colors
+ 'aliceblue', 'antiquewhite', 'aquamarine', 'azure',
+ 'beige', 'bisque', 'blanchedalmond', 'blueviolet', 'brown',
+ 'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
+ 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
+ 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
+ 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
+ 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
+ 'darksalmon', 'darkseagreen', 'darkslateblue',
+ 'darkslategray', 'darkslategrey', 'darkturquoise',
+ 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
+ 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
+ 'forestgreen', 'gainsboro', 'ghostwhite', 'gold',
+ 'goldenrod', 'greenyellow', 'grey', 'honeydew', 'hotpink',
+ 'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
+ 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
+ 'lightcoral', 'lightcyan', 'lightgoldenrodyellow',
+ 'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
+ 'lightsalmon', 'lightseagreen', 'lightskyblue',
+ 'lightslategray', 'lightslategrey', 'lightsteelblue',
+ 'lightyellow', 'limegreen', 'linen', 'magenta',
+ 'mediumaquamarine', 'mediumblue', 'mediumorchid',
+ 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
+ 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
+ 'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
+ 'navajowhite', 'oldlace', 'olivedrab', 'orange',
+ 'orangered', 'orchid', 'palegoldenrod', 'palegreen',
+ 'paleturquoise', 'palevioletred', 'papayawhip',
+ 'peachpuff', 'peru', 'pink', 'plum', 'powderblue',
+ 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
+ 'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue',
+ 'slateblue', 'slategray', 'slategrey', 'snow',
+ 'springgreen', 'steelblue', 'tan', 'thistle', 'tomato',
+ 'turquoise', 'violet', 'wheat', 'whitesmoke',
+ 'yellowgreen',
+ // Other keywords. Intentionally omitting the deprecated system colors.
+ 'transparent', 'currentColor',
+ ] ),
+ new TokenMatcher( Token::T_HASH, function ( Token $t ) {
+ return preg_match( '/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $t->value() );
+ } ),
+ ], $this->colorFuncs() ) );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for an image value
+ * @see https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-values
+ * @return Matcher
+ */
+ public function image() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ // https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-list-type
+ // Note the undefined production has been dropped from the Editor's Draft.
+ $imageDecl = new Alternative( [
+ $this->url( 'image' ),
+ $this->urlstring( 'image' ),
+ ] );
+
+ // https://www.w3.org/TR/2012/CR-css3-images-20120417/#gradients
+ $c = $this->comma();
+ $colorStops = Quantifier::hash( new Juxtaposition( [
+ $this->color(),
+ // Not really , but grammatically the same
+ Quantifier::optional( $this->lengthPercentage() ),
+ ] ), 2, INF );
+ $atPosition = new Juxtaposition( [ new KeywordMatcher( 'at' ), $this->position() ] );
+
+ $linearGradient = new Juxtaposition( [
+ Quantifier::optional( new Juxtaposition( [
+ new Alternative( [
+ $this->angle(),
+ new Juxtaposition( [ new KeywordMatcher( 'to' ), UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'left', 'right' ] ),
+ new KeywordMatcher( [ 'top', 'bottom' ] ),
+ ] ) ] )
+ ] ),
+ $c
+ ] ) ),
+ $colorStops,
+ ] );
+ $radialGradient = new Juxtaposition( [
+ Quantifier::optional( new Juxtaposition( [
+ new Alternative( [
+ new Juxtaposition( [
+ new Alternative( [
+ UnorderedGroup::someOf( [ new KeywordMatcher( 'circle' ), $this->length() ] ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'ellipse' ),
+ // Not really , but grammatically the same
+ Quantifier::count( $this->lengthPercentage(), 2, 2 )
+ ] ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'circle', 'ellipse' ] ),
+ new KeywordMatcher( [
+ 'closest-side', 'farthest-side', 'closest-corner', 'farthest-corner'
+ ] ),
+ ] ),
+ ] ),
+ Quantifier::optional( $atPosition ),
+ ] ),
+ $atPosition
+ ] ),
+ $c
+ ] ) ),
+ $colorStops,
+ ] );
+
+ // Putting it all together
+ $this->cache[__METHOD__] = new Alternative( [
+ $this->url( 'image' ),
+ new FunctionMatcher( 'image', new Juxtaposition( [
+ Quantifier::star( new Juxtaposition( [ $imageDecl, $c ] ) ),
+ new Alternative( [ $imageDecl, $this->color() ] ),
+ ] ) ),
+ new FunctionMatcher( 'linear-gradient', $linearGradient ),
+ new FunctionMatcher( 'radial-gradient', $radialGradient ),
+ new FunctionMatcher( 'repeating-linear-gradient', $linearGradient ),
+ new FunctionMatcher( 'repeating-radial-gradient', $radialGradient ),
+ ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a position value
+ * @see https://www.w3.org/TR/2014/CR-css3-background-20140909/#ltpositiongt
+ * @return Matcher
+ */
+ public function position() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $lp = $this->lengthPercentage();
+ $olp = Quantifier::optional( $lp );
+ $center = new KeywordMatcher( 'center' );
+ $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
+ $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
+
+ $this->cache[__METHOD__] = new Alternative( [
+ new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
+ new Juxtaposition( [
+ new Alternative( [ $center, $leftRight, $lp ] ),
+ new Alternative( [ $center, $topBottom, $lp ] ),
+ ] ),
+ UnorderedGroup::allOf( [
+ new Alternative( [ $center, new Juxtaposition( [ $leftRight, $olp ] ) ] ),
+ new Alternative( [ $center, new Juxtaposition( [ $topBottom, $olp ] ) ] ),
+ ] ),
+ ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * Matcher for a CSS media query
+ * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
+ * @param bool $strict Only allow defined query types
+ * @return Matcher
+ */
+ public function cssMediaQuery( $strict = true ) {
+ $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
+ if ( !isset( $this->cache[$key] ) ) {
+ if ( $strict ) {
+ $generalEnclosed = new NothingMatcher();
+
+ $mediaType = new KeywordMatcher( [
+ 'all', 'print', 'screen', 'speech',
+ // deprecated
+ 'tty', 'tv', 'projection', 'handheld', 'braille', 'embossed', 'aural'
+ ] );
+
+ $rangeFeatures = [
+ 'width', 'height', 'aspect-ratio', 'resolution', 'color', 'color-index', 'monochrome',
+ // deprecated
+ 'device-width', 'device-height', 'device-aspect-ratio'
+ ];
+ $discreteFeatures = [
+ 'orientation', 'scan', 'grid', 'update', 'overflow-block', 'overflow-inline', 'color-gamut',
+ 'pointer', 'hover', 'any-pointer', 'any-hover', 'scripting'
+ ];
+ $mfName = new KeywordMatcher( array_merge(
+ $rangeFeatures,
+ array_map( function ( $f ) {
+ return "min-$f";
+ }, $rangeFeatures ),
+ array_map( function ( $f ) {
+ return "max-$f";
+ }, $rangeFeatures ),
+ $discreteFeatures
+ ) );
+ } else {
+ $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
+ $generalEnclosed = new Alternative( [
+ new FunctionMatcher( null, $anythingPlus ),
+ new BlockMatcher( Token::T_LEFT_PAREN,
+ new Juxtaposition( [ $this->ident(), $anythingPlus ] )
+ ),
+ ] );
+ $mediaType = $this->ident();
+ $mfName = $this->ident();
+ }
+
+ $posInt = $this->calc(
+ new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^\+?\d+$/', $t->representation() );
+ } ),
+ 'integer'
+ );
+ $eq = new DelimMatcher( '=' );
+ $oeq = Quantifier::optional( new Juxtaposition( [ new NoWhitespace, $eq ] ) );
+ $ltgteq = Quantifier::optional( new Alternative( [
+ $eq,
+ new Juxtaposition( [ new DelimMatcher( [ '<', '>' ] ), $oeq ] ),
+ ] ) );
+ $lteq = new Juxtaposition( [ new DelimMatcher( '<' ), $oeq ] );
+ $gteq = new Juxtaposition( [ new DelimMatcher( '>' ), $oeq ] );
+ $mfValue = new Alternative( [
+ $this->number(),
+ $this->dimension(),
+ $this->ident(),
+ new Juxtaposition( [ $posInt, new DelimMatcher( '/' ), $posInt ] ),
+ ] );
+
+ $mediaInParens = new NothingMatcher(); // temporary
+ $mediaNot = new Juxtaposition( [ new KeywordMatcher( 'not' ), &$mediaInParens ] );
+ $mediaAnd = new Juxtaposition( [
+ &$mediaInParens,
+ Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'and' ), &$mediaInParens ] ) )
+ ] );
+ $mediaOr = new Juxtaposition( [
+ &$mediaInParens,
+ Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'or' ), &$mediaInParens ] ) )
+ ] );
+ $mediaCondition = new Alternative( [ $mediaNot, $mediaAnd, $mediaOr, &$mediaInParens ] );
+ $mediaConditionWithoutOr = new Alternative( [ $mediaNot, $mediaAnd, &$mediaInParens ] );
+ $mediaFeature = new BlockMatcher( Token::T_LEFT_PAREN, new Alternative( [
+ new Juxtaposition( [ $mfName, new TokenMatcher( Token::T_COLON ), $mfValue ] ), //
+ $mfName, //
+ new Juxtaposition( [ $mfName, $ltgteq, $mfValue ] ), // , 1st alternative
+ new Juxtaposition( [ $mfValue, $ltgteq, $mfName ] ), // , 2nd alternative
+ new Juxtaposition( [ $mfValue, $lteq, $mfName, $lteq, $mfValue ] ), // , 3rd alt
+ new Juxtaposition( [ $mfValue, $gteq, $mfName, $gteq, $mfValue ] ), // , 4th alt
+ ] ) );
+ $mediaInParens = new Alternative( [
+ new BlockMatcher( Token::T_LEFT_PAREN, $mediaCondition ),
+ $mediaFeature,
+ $generalEnclosed,
+ ] );
+
+ $this->cache[$key] = new Alternative( [
+ $mediaCondition,
+ new Juxtaposition( [
+ Quantifier::optional( new KeywordMatcher( [ 'not', 'only' ] ) ),
+ $mediaType,
+ Quantifier::optional( new Juxtaposition( [
+ new KeywordMatcher( 'and' ),
+ $mediaConditionWithoutOr,
+ ] ) )
+ ] )
+ ] );
+ }
+
+ return $this->cache[$key];
+ }
+
+ /**
+ * Matcher for a CSS media query list
+ * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
+ * @param bool $strict Only allow defined query types
+ * @return Matcher
+ */
+ public function cssMediaQueryList( $strict = true ) {
+ $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
+ if ( !isset( $this->cache[$key] ) ) {
+ $this->cache[$key] = Quantifier::hash( $this->cssMediaQuery( $strict ), 0, INF );
+ }
+
+ return $this->cache[$key];
+ }
+
+ /************************************************************************//**
+ * @name CSS Selectors Level 3
+ * @{
+ *
+ * https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#w3cselgrammar
+ */
+
+ /**
+ * List of selectors
+ *
+ * selector [ COMMA S* selector ]*
+ *
+ * Capturing is set up for the `selector`s.
+ *
+ * @return Matcher
+ */
+ public function cssSelectorList() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ // Technically the spec doesn't allow whitespace before the comma,
+ // but I'd guess every browser does. So just use Quantifier::hash.
+ $selector = $this->cssSelector()->capture( 'selector' );
+ $this->cache[__METHOD__] = Quantifier::hash( $selector );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A single selector
+ *
+ * simple_selector_sequence [ combinator simple_selector_sequence ]*
+ *
+ * Capturing is set up for the `simple_selector_sequence`s (as 'simple') and `combinator`.
+ *
+ * @return Matcher
+ */
+ public function cssSelector() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $simple = $this->cssSimpleSelectorSeq()->capture( 'simple' );
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ $simple,
+ Quantifier::star( new Juxtaposition( [
+ $this->cssCombinator()->capture( 'combinator' ),
+ $simple,
+ ] ) )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A CSS combinator
+ *
+ * PLUS S* | GREATER S* | TILDE S* | S+
+ *
+ * (combinators can be surrounded by whitespace)
+ *
+ * @return Matcher
+ */
+ public function cssCombinator() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Alternative( [
+ new Juxtaposition( [
+ $this->optionalWhitespace(),
+ new DelimMatcher( [ '+', '>', '~' ] ),
+ $this->optionalWhitespace(),
+ ] ),
+ $this->significantWhitespace(),
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A simple selector sequence
+ *
+ * [ type_selector | universal ]
+ * [ HASH | class | attrib | pseudo | negation ]*
+ * | [ HASH | class | attrib | pseudo | negation ]+
+ *
+ * The following captures are set:
+ * - element: [ type_selector | universal ]
+ * - id: HASH
+ * - class: class
+ * - attrib: attrib
+ * - pseudo: pseudo
+ * - negation: negation
+ *
+ * @return Matcher
+ */
+ public function cssSimpleSelectorSeq() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $hashEtc = new Alternative( [
+ $this->cssID()->capture( 'id' ),
+ $this->cssClass()->capture( 'class' ),
+ $this->cssAttrib()->capture( 'attrib' ),
+ $this->cssPseudo()->capture( 'pseudo' ),
+ $this->cssNegation()->capture( 'negation' ),
+ ] );
+
+ $this->cache[__METHOD__] = new Alternative( [
+ new Juxtaposition( [
+ Alternative::create( [
+ $this->cssTypeSelector(),
+ $this->cssUniversal(),
+ ] )->capture( 'element' ),
+ Quantifier::star( $hashEtc )
+ ] ),
+ Quantifier::plus( $hashEtc )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A type selector (i.e. a tag name)
+ *
+ * [ namespace_prefix ] ? element_name
+ *
+ * where element_name is
+ *
+ * IDENT
+ *
+ * @return Matcher
+ */
+ public function cssTypeSelector() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ $this->cssOptionalNamespacePrefix(),
+ new TokenMatcher( Token::T_IDENT )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A namespace prefix
+ *
+ * [ IDENT | '*' ]? '|'
+ *
+ * @return Matcher
+ */
+ public function cssNamespacePrefix() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ Quantifier::optional( new Alternative( [
+ $this->ident(),
+ new DelimMatcher( [ '*' ] ),
+ ] ) ),
+ new DelimMatcher( [ '|' ] ),
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * An optional namespace prefix
+ *
+ * [ namespace_prefix ]?
+ *
+ * @return Matcher
+ */
+ private function cssOptionalNamespacePrefix() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = Quantifier::optional( $this->cssNamespacePrefix() );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * The universal selector
+ *
+ * [ namespace_prefix ]? '*'
+ *
+ * @return Matcher
+ */
+ public function cssUniversal() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ $this->cssOptionalNamespacePrefix(),
+ new DelimMatcher( [ '*' ] )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * An ID selector
+ *
+ * HASH
+ *
+ * @return Matcher
+ */
+ public function cssID() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new TokenMatcher( Token::T_HASH, function ( Token $t ) {
+ return $t->typeFlag() === 'id';
+ } );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A class selector
+ *
+ * '.' IDENT
+ *
+ * @return Matcher
+ */
+ public function cssClass() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ new DelimMatcher( [ '.' ] ),
+ $this->ident()
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * An attribute selector
+ *
+ * '[' S* [ namespace_prefix ]? IDENT S*
+ * [ [ PREFIXMATCH |
+ * SUFFIXMATCH |
+ * SUBSTRINGMATCH |
+ * '=' |
+ * INCLUDES |
+ * DASHMATCH ] S* [ IDENT | STRING ] S*
+ * ]? ']'
+ *
+ * Captures are set for the attribute, test, and value. Note that these
+ * captures will probably be relative to the contents of the SimpleBlock
+ * that this matcher matches!
+ *
+ * @return Matcher
+ */
+ public function cssAttrib() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ // An attribute is going to be parsed by the parser as a
+ // SimpleBlock, so that's what we need to look for here.
+
+ $this->cache[__METHOD__] = new BlockMatcher( Token::T_LEFT_BRACKET,
+ new Juxtaposition( [
+ $this->optionalWhitespace(),
+ Juxtaposition::create( [
+ $this->cssOptionalNamespacePrefix(),
+ $this->ident(),
+ ] )->capture( 'attribute' ),
+ $this->optionalWhitespace(),
+ Quantifier::optional( new Juxtaposition( [
+ Alternative::create( [
+ new TokenMatcher( Token::T_PREFIX_MATCH ),
+ new TokenMatcher( Token::T_SUFFIX_MATCH ),
+ new TokenMatcher( Token::T_SUBSTRING_MATCH ),
+ new DelimMatcher( [ '=' ] ),
+ new TokenMatcher( Token::T_INCLUDE_MATCH ),
+ new TokenMatcher( Token::T_DASH_MATCH ),
+ ] )->capture( 'test' ),
+ $this->optionalWhitespace(),
+ Alternative::create( [
+ $this->ident(),
+ $this->string(),
+ ] )->capture( 'value' ),
+ $this->optionalWhitespace(),
+ ] ) ),
+ ] )
+ );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A pseudo-class or pseudo-element
+ *
+ * ':' ':'? [ IDENT | functional_pseudo ]
+ *
+ * Although this actually only matches the pseudo-selectors defined in the
+ * following sources:
+ * - https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#pseudo-classes
+ * - https://www.w3.org/TR/2016/WD-css-pseudo-4-20160607/
+ *
+ * @return Matcher
+ */
+ public function cssPseudo() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ $colon = new TokenMatcher( Token::T_COLON );
+ $ows = $this->optionalWhitespace();
+ $anplusb = new Juxtaposition( [ $ows, $this->cssANplusB(), $ows ] );
+ $this->cache[__METHOD__] = new Alternative( [
+ new Juxtaposition( [
+ $colon,
+ new Alternative( [
+ new KeywordMatcher( [
+ 'link', 'visited', 'hover', 'active', 'focus', 'target', 'enabled', 'disabled', 'checked',
+ 'indeterminate', 'root', 'first-child', 'last-child', 'first-of-type',
+ 'last-of-type', 'only-child', 'only-of-type', 'empty',
+ // CSS2-compat elements with class syntax
+ 'first-line', 'first-letter', 'before', 'after',
+ ] ),
+ new FunctionMatcher( 'lang', new Juxtaposition( [ $ows, $this->ident(), $ows ] ) ),
+ new FunctionMatcher( 'nth-child', $anplusb ),
+ new FunctionMatcher( 'nth-last-child', $anplusb ),
+ new FunctionMatcher( 'nth-of-type', $anplusb ),
+ new FunctionMatcher( 'nth-last-of-type', $anplusb ),
+ ] ),
+ ] ),
+ new Juxtaposition( [
+ $colon,
+ $colon,
+ new KeywordMatcher( [
+ 'first-line', 'first-letter', 'before', 'after', 'selection', 'inactive-selection',
+ 'spelling-error', 'grammar-error', 'placeholder'
+ ] ),
+ ] ),
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * An "AN+B" form
+ *
+ * https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#anb
+ *
+ * @return Matcher
+ */
+ public function cssANplusB() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ // Quoth the spec:
+ // > The An+B notation was originally defined using a slightly
+ // > different tokenizer than the rest of CSS, resulting in a
+ // > somewhat odd definition when expressed in terms of CSS tokens.
+ // That's a bit of an understatement
+
+ $plus = new DelimMatcher( [ '+' ] );
+ $plusQ = Quantifier::optional( new DelimMatcher( [ '+' ] ) );
+ $n = new KeywordMatcher( [ 'n' ] );
+ $dashN = new KeywordMatcher( [ '-n' ] );
+ $nDash = new KeywordMatcher( [ 'n-' ] );
+ $plusQN = new Juxtaposition( [ $plusQ, $n ] );
+ $plusQNDash = new Juxtaposition( [ $plusQ, $nDash ] );
+ $nDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n' );
+ } );
+ $nDashDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n-' );
+ } );
+ $nDashDigitDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^n-\d+$/i', $t->unit() );
+ } );
+ $nDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
+ return preg_match( '/^n-\d+$/i', $t->value() );
+ } );
+ $dashNDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
+ return preg_match( '/^-n-\d+$/i', $t->value() );
+ } );
+ $signedInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^[+-]/', $t->representation() );
+ } );
+ $signlessInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^\d/', $t->representation() );
+ } );
+ $plusOrMinus = new DelimMatcher( [ '+', '-' ] );
+ $S = $this->optionalWhitespace();
+
+ $this->cache[__METHOD__] = new Alternative( [
+ new KeywordMatcher( [ 'odd', 'even' ] ),
+ new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer';
+ } ),
+ $nDimension,
+ $plusQN,
+ $dashN,
+ $nDashDigitDimension,
+ new Juxtaposition( [ $plusQ, $nDashDigitIdent ] ),
+ $dashNDashDigitIdent,
+ new Juxtaposition( [ $nDimension, $S, $signedInt ] ),
+ new Juxtaposition( [ $plusQN, $S, $signedInt ] ),
+ new Juxtaposition( [ $dashN, $S, $signedInt ] ),
+ new Juxtaposition( [ $nDashDimension, $S, $signlessInt ] ),
+ new Juxtaposition( [ $plusQNDash, $S, $signlessInt ] ),
+ new Juxtaposition( [ new KeywordMatcher( [ '-n-' ] ), $S, $signlessInt ] ),
+ new Juxtaposition( [ $nDimension, $S, $plusOrMinus, $S, $signlessInt ] ),
+ new Juxtaposition( [ $plusQN, $S, $plusOrMinus, $S, $signlessInt ] ),
+ new Juxtaposition( [ $dashN, $S, $plusOrMinus, $S, $signlessInt ] )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**
+ * A negation
+ *
+ * ':' not( S* [ type_selector | universal | HASH | class | attrib | pseudo ] S* ')'
+ *
+ * @return Matcher
+ */
+ public function cssNegation() {
+ if ( !isset( $this->cache[__METHOD__] ) ) {
+ // A negation is going to be parsed by the parser as a colon
+ // followed by a CSSFunction, so that's what we need to look for
+ // here.
+
+ $this->cache[__METHOD__] = new Juxtaposition( [
+ new TokenMatcher( Token::T_COLON ),
+ new FunctionMatcher( 'not',
+ new Juxtaposition( [
+ $this->optionalWhitespace(),
+ new Alternative( [
+ $this->cssTypeSelector(),
+ $this->cssUniversal(),
+ $this->cssID(),
+ $this->cssClass(),
+ $this->cssAttrib(),
+ $this->cssPseudo(),
+ ] ),
+ $this->optionalWhitespace(),
+ ] )
+ )
+ ] );
+ $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+ }
+ return $this->cache[__METHOD__];
+ }
+
+ /**@}*/
+
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/NoWhitespace.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NoWhitespace.php
new file mode 100644
index 000000000..92f90c7f5
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NoWhitespace.php
@@ -0,0 +1,23 @@
+type() !== Token::T_WHITESPACE ) {
+ yield $this->makeMatch( $values, $start, $start );
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/NonEmpty.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NonEmpty.php
new file mode 100644
index 000000000..b8810b350
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NonEmpty.php
@@ -0,0 +1,33 @@
+matcher = $matcher;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ foreach ( $this->matcher->generateMatches( $values, $start, $options ) as $match ) {
+ if ( $match->getLength() !== 0 ) {
+ yield $this->makeMatch( $values, $start, $match->getNext(), $match );
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/NothingMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NothingMatcher.php
new file mode 100644
index 000000000..246e1ed04
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/NothingMatcher.php
@@ -0,0 +1,19 @@
+matcher = $matcher;
+ $this->min = $min;
+ $this->max = $max;
+ $this->commas = (bool)$commas;
+ }
+
+ /**
+ * Implements "?": 0 or 1 matches
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#mult-opt
+ * @param Matcher $matcher
+ * @return static
+ */
+ public static function optional( Matcher $matcher ) {
+ return new static( $matcher, 0, 1, false );
+ }
+
+ /**
+ * Implements "*": 0 or more matches
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#mult-zero-plus
+ * @param Matcher $matcher
+ * @return static
+ */
+ public static function star( Matcher $matcher ) {
+ return new static( $matcher, 0, INF, false );
+ }
+
+ /**
+ * Implements "+": 1 or more matches
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#mult-one-plus
+ * @param Matcher $matcher
+ * @return static
+ */
+ public static function plus( Matcher $matcher ) {
+ return new static( $matcher, 1, INF, false );
+ }
+
+ /**
+ * Implements "{A,B}": Between A and B matches
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#mult-num-range
+ * @param Matcher $matcher
+ * @param int|float $min Minimum number of matches
+ * @param int|float $max Maximum number of matches
+ * @return static
+ */
+ public static function count( Matcher $matcher, $min, $max ) {
+ return new static( $matcher, $min, $max, false );
+ }
+
+ /**
+ * Implements "#" and "#{A,B}": Between A and B matches, comma-separated
+ * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#mult-comma
+ * @param Matcher $matcher
+ * @param int|float $min Minimum number of matches
+ * @param int|float $max Maximum number of matches
+ * @return static
+ */
+ public static function hash( Matcher $matcher, $min = 1, $max = INF ) {
+ return new static( $matcher, $min, $max, true );
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $used = [];
+
+ // Maintain a stack of matches for backtracking purposes.
+ $stack = [
+ [ new Match( $values, $start, 0 ), $this->matcher->generateMatches( $values, $start, $options ) ]
+ ];
+ do {
+ /** @var $lastMatch Match */
+ /** @var $iter \Iterator */
+ list( $lastMatch, $iter ) = $stack[count( $stack ) - 1];
+
+ // If the top of the stack has no more matches, pop it, maybe
+ // yield the last matched position, and loop.
+ if ( !$iter->valid() ) {
+ array_pop( $stack );
+ $ct = count( $stack );
+ $pos = $lastMatch->getNext();
+ if ( $ct >= $this->min && $ct <= $this->max ) {
+ $newMatch = $this->makeMatch( $values, $start, $pos, $lastMatch, $stack );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ }
+ continue;
+ }
+
+ // Find the next match for the current top of the stack.
+ $match = $iter->current();
+ $iter->next();
+
+ // Quantifiers don't work well when the quantified thing can be empty.
+ if ( $match->getLength() === 0 ) {
+ throw new \UnexpectedValueException( 'Empty match in quantifier!' );
+ }
+
+ $nextFrom = $match->getNext();
+
+ // There can only be more matches after this one if we haven't
+ // reached our maximum yet.
+ $canBeMore = count( $stack ) < $this->max;
+
+ // Commas are slightly tricky:
+ // 1. If there is a following comma, start the next Matcher after it.
+ // 2. If not, there can't be any more Matchers following.
+ // And in either case optional whitespace is always allowed.
+ if ( $this->commas ) {
+ $n = $nextFrom;
+ if ( isset( $values[$n] ) && $values[$n] instanceof Token &&
+ $values[$n]->type() === Token::T_WHITESPACE
+ ) {
+ $n = $this->next( $values, $n, [ 'skip-whitespace' => true ] + $options );
+ }
+ if ( isset( $values[$n] ) && $values[$n] instanceof Token &&
+ $values[$n]->type() === Token::T_COMMA
+ ) {
+ $nextFrom = $this->next( $values, $n, [ 'skip-whitespace' => true ] + $options );
+ } else {
+ $canBeMore = false;
+ }
+ }
+
+ // If there can be more matches, push another one onto the stack
+ // and try it. Otherwise yield and continue with the current match.
+ if ( $canBeMore ) {
+ $stack[] = [ $match, $this->matcher->generateMatches( $values, $nextFrom, $options ) ];
+ } else {
+ $ct = count( $stack );
+ $pos = $match->getNext();
+ if ( $ct >= $this->min && $ct <= $this->max ) {
+ $newMatch = $this->makeMatch( $values, $start, $pos, $match, $stack );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ }
+ }
+ } while ( $stack );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/TokenMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/TokenMatcher.php
new file mode 100644
index 000000000..092221875
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/TokenMatcher.php
@@ -0,0 +1,41 @@
+type = $type;
+ $this->callback = $callback;
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof Token && $cv->type() === $this->type &&
+ ( !$this->callback || call_user_func( $this->callback, $cv ) )
+ ) {
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ) );
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/UnorderedGroup.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/UnorderedGroup.php
new file mode 100644
index 000000000..9152e906d
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/UnorderedGroup.php
@@ -0,0 +1,124 @@
+matchers = $matchers;
+ $this->all = (bool)$all;
+ }
+
+ /**
+ * Implements "&&": All of the options, in any order
+ * @param Matcher[] $matchers
+ * @return static
+ */
+ public static function allOf( array $matchers ) {
+ return new static( $matchers, true );
+ }
+
+ /**
+ * Implements "||": One or more of the options, in any order
+ * @param Matcher[] $matchers
+ * @return static
+ */
+ public static function someOf( array $matchers ) {
+ return new static( $matchers, false );
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $used = [];
+
+ // As each Matcher is used, push it onto the stack along with the set
+ // of remaining matchers.
+ $stack = [
+ [
+ new Match( $values, $start, 0 ),
+ $this->matchers,
+ new \ArrayIterator( $this->matchers ),
+ null,
+ new \EmptyIterator
+ ]
+ ];
+ do {
+ /** @var $lastMatch Match */
+ /** @var $matchers Matcher[] */
+ /** @var $matcherIter \Iterator */
+ /** @var $curMatcher Matcher|null */
+ /** @var $iter \Iterator */
+ list( $lastMatch, $matchers, $matcherIter, $curMatcher, $iter ) = $stack[count( $stack ) - 1];
+
+ // If the top of the stack has more matches, process the next one.
+ if ( $iter->valid() ) {
+ $match = $iter->current();
+ $iter->next();
+
+ // If we have unused matchers to try after this one, do so.
+ // Otherwise yield and continue with the current one.
+ if ( $matchers ) {
+ $stack[] = [ $match, $matchers, new \ArrayIterator( $matchers ), null, new \EmptyIterator ];
+ } else {
+ $newMatch = $this->makeMatch( $values, $start, $match->getNext(), $match, $stack );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ }
+ continue;
+ }
+
+ // We ran out of matches for the current top of the stack. Pop it,
+ // and put $curMatcher back into $matchers so it can be tried again
+ // at a later position.
+ array_pop( $stack );
+ if ( $curMatcher ) {
+ $matchers[$matcherIter->key()] = $curMatcher;
+ $matcherIter->next();
+ }
+
+ $fromPos = $lastMatch->getNext();
+
+ // If there are more matchers to try, pull the next one out of
+ // $matchers and try it at the current position. Otherwise, maybe
+ // yield the current position and backtrack.
+ if ( $matcherIter->valid() ) {
+ $curMatcher = $matcherIter->current();
+ unset( $matchers[$matcherIter->key()] );
+ $iter = $curMatcher->generateMatches( $values, $fromPos, $options );
+ $stack[] = [ $lastMatch, $matchers, $matcherIter, $curMatcher, $iter ];
+ } else {
+ if ( $stack && !$this->all ) {
+ $newMatch = $this->makeMatch( $values, $start, $fromPos, $lastMatch, $stack );
+ $mid = $newMatch->getUniqueID();
+ if ( !isset( $used[$mid] ) ) {
+ $used[$mid] = 1;
+ yield $newMatch;
+ }
+ }
+ }
+ } while ( $stack );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/UrlMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/UrlMatcher.php
new file mode 100644
index 000000000..d427ef500
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/UrlMatcher.php
@@ -0,0 +1,91 @@
+capture( 'url' ),
+ Quantifier::star( $modifierMatcher->capture( 'modifier' ) ),
+ ] );
+
+ $this->urlCheck = $urlCheck;
+ parent::__construct( 'url', $funcContents );
+ }
+
+ /**
+ * Return a Matcher for any grammatically-correct modifier
+ * @return Matcher
+ */
+ public static function anyModifierMatcher() {
+ return Alternative::create( [
+ new TokenMatcher( Token::T_IDENT ),
+ new FunctionMatcher( null, new AnythingMatcher( [ 'quantifier' => '*' ] ) ),
+ ] );
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ // First, is it a URL token?
+ $cv = isset( $values[$start] ) ? $values[$start] : null;
+ if ( $cv instanceof Token && $cv->type() === Token::T_URL ) {
+ $url = $cv->value();
+ if ( !$this->urlCheck || call_user_func( $this->urlCheck, $url, [] ) ) {
+ $match = new Match( $values, $start, 1, 'url' );
+ yield $this->makeMatch( $values, $start, $this->next( $values, $start, $options ), $match );
+ }
+ return;
+ }
+
+ // Nope. Try it as a FunctionMatcher and extract the URL and modifiers
+ // for each match.
+ foreach ( parent::generateMatches( $values, $start, $options ) as $match ) {
+ $url = null;
+ $modifiers = [];
+ foreach ( $match->getCapturedMatches() as $submatch ) {
+ $cvs = $submatch->getValues();
+ if ( $submatch->getName() === 'url' ) {
+ $url = $cvs[0]->value();
+ } elseif ( $submatch->getName() === 'modifier' ) {
+ if ( $cvs[0] instanceof CSSFunction ) {
+ $modifiers[] = $cvs[0];
+ } elseif ( $cvs[0]->type() === Token::T_IDENT ) {
+ $modifiers[] = $cvs[0];
+ }
+ }
+ }
+ if ( $url && ( !$this->urlCheck || call_user_func( $this->urlCheck, $url, $modifiers ) ) ) {
+ yield $match;
+ }
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Grammar/WhitespaceMatcher.php b/lib/css-sanitizer/Wikimedia/CSS/Grammar/WhitespaceMatcher.php
new file mode 100644
index 000000000..9aa074a4d
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Grammar/WhitespaceMatcher.php
@@ -0,0 +1,60 @@
+*` (false) or `+` (true).
+ */
+ public function __construct( array $options = [] ) {
+ $this->significant = !empty( $options['significant'] );
+ }
+
+ protected function generateMatches( ComponentValueList $values, $start, array $options ) {
+ $end = $start;
+ while ( isset( $values[$end] ) &&
+ $values[$end] instanceof Token && $values[$end]->type() === Token::T_WHITESPACE
+ ) {
+ $end++;
+ }
+
+ // If it's not significant, return whatever we found.
+ if ( !$this->significant ) {
+ yield $this->makeMatch( $values, $start, $end );
+ return;
+ }
+
+ // If we found zero whitespace, $options says we're skipping
+ // whitespace, and whitespace was actually skipped, rewind one token.
+ // Otherwise, return no match.
+ if ( $end === $start ) {
+ $start--;
+ if ( !$options['skip-whitespace'] || !isset( $values[$start] ) ||
+ !$values[$start] instanceof Token || $values[$start]->type() !== Token::T_WHITESPACE
+ ) {
+ return;
+ }
+ }
+
+ // Return the match. Include a 'significantWhitespace' capture.
+ yield $this->makeMatch( $values, $start, $end,
+ new Match( $values, $start, 1, 'significantWhitespace' )
+ );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/AtRule.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/AtRule.php
new file mode 100644
index 000000000..1dfc26e7c
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/AtRule.php
@@ -0,0 +1,126 @@
+type() !== Token::T_AT_KEYWORD ) {
+ throw new \InvalidArgumentException(
+ "At rule must begin with an at-keyword token, got {$token->type()}"
+ );
+ }
+
+ parent::__construct( $token );
+ $this->name = $token->value();
+ $this->prelude = new ComponentValueList();
+ }
+
+ public function __clone() {
+ $this->prelude = clone( $this->prelude );
+ if ( $this->block ) {
+ $this->block = clone( $this->block );
+ }
+ }
+
+ /**
+ * Create an at-rule by name
+ * @param string $name
+ * @return AtRule
+ */
+ public static function newFromName( $name ) {
+ return new static( new Token( Token::T_AT_KEYWORD, $name ) );
+ }
+
+ /**
+ * Return the at-rule's name, e.g. "media"
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Return the at-rule's prelude
+ * @return ComponentValueList
+ */
+ public function getPrelude() {
+ return $this->prelude;
+ }
+
+ /**
+ * Return the at-rule's block
+ * @return SimpleBlock|null
+ */
+ public function getBlock() {
+ return $this->block;
+ }
+
+ /**
+ * Set the block
+ * @param SimpleBlock|null $block
+ */
+ public function setBlock( SimpleBlock $block = null ) {
+ if ( $block->getStartTokenType() !== Token::T_LEFT_BRACE ) {
+ throw new \InvalidArgumentException( 'At-rule block must be delimited by {}' );
+ }
+ $this->block = $block;
+ }
+
+ /**
+ * @param string $function Function to call, toTokenArray() or toComponentValueArray()
+ */
+ private function toTokenOrCVArray( $function ) {
+ $ret = [];
+
+ $ret[] = new Token(
+ Token::T_AT_KEYWORD, [ 'value' => $this->name, 'position' => [ $this->line, $this->pos ] ]
+ );
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ foreach ( $this->prelude->$function() as $v ) {
+ $ret[] = $v;
+ }
+ if ( $this->block ) {
+ foreach ( $this->block->$function() as $v ) {
+ $ret[] = $v;
+ }
+ } else {
+ $ret[] = new Token( Token::T_SEMICOLON );
+ }
+
+ return $ret;
+ }
+
+ public function toTokenArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function toComponentValueArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSFunction.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSFunction.php
new file mode 100644
index 000000000..ee03edf8c
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSFunction.php
@@ -0,0 +1,89 @@
+type() !== Token::T_FUNCTION ) {
+ throw new \InvalidArgumentException(
+ "CSS function must begin with a function token, got {$token->type()}"
+ );
+ }
+
+ list( $this->line, $this->pos ) = $token->getPosition();
+ $this->name = $token->value();
+ $this->value = new ComponentValueList();
+ }
+
+ public function __clone() {
+ $this->value = clone( $this->value );
+ }
+
+ /**
+ * Create a function by name
+ * @param string $name
+ * @return CSSFunction
+ */
+ public static function newFromName( $name ) {
+ return new static( new Token( Token::T_FUNCTION, $name ) );
+ }
+
+ /**
+ * Return the functions's name
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Return the function's value
+ * @return ComponentValueList
+ */
+ public function getValue() {
+ return $this->value;
+ }
+
+ /**
+ * Return an array of Tokens that correspond to this object.
+ * @return Token[]
+ */
+ public function toTokenArray() {
+ $ret = [];
+
+ $ret[] = new Token(
+ Token::T_FUNCTION,
+ [ 'value' => $this->name, 'position' => [ $this->line, $this->pos ] ]
+ );
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ foreach ( $this->value->toTokenArray() as $v ) {
+ $ret[] = $v;
+ }
+ $ret[] = new Token( Token::T_RIGHT_PAREN );
+
+ return $ret;
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSObject.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSObject.php
new file mode 100644
index 000000000..9a1bbcc3f
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/CSSObject.php
@@ -0,0 +1,39 @@
+objects = array_values( $objects );
+ }
+
+ /**
+ * Insert one or more objects into the list
+ * @param CSSObject|CSSObject[]|CSSObjectList $objects An object to add, or an array of objects.
+ * @param int $index Insert the objects at this index. If omitted, the
+ * objects are added at the end.
+ */
+ public function add( $objects, $index = null ) {
+ if ( $objects instanceof static ) {
+ $objects = $objects->objects;
+ } elseif ( is_array( $objects ) ) {
+ Util::assertAllInstanceOf( $objects, static::$objectType, static::class );
+ $objects = array_values( $objects );
+ static::testObjects( $objects );
+ } else {
+ if ( !$objects instanceof static::$objectType ) {
+ throw new \InvalidArgumentException(
+ static::class . ' may only contain instances of ' . static::$objectType . '.'
+ );
+ }
+ $objects = [ $objects ];
+ static::testObjects( $objects );
+ }
+
+ if ( $index === null ) {
+ $index = count( $this->objects );
+ } elseif ( $index < 0 || $index > count( $this->objects ) ) {
+ throw new \OutOfBoundsException( 'Index is out of range.' );
+ }
+
+ array_splice( $this->objects, $index, 0, $objects );
+ if ( $this->offset > $index ) {
+ $this->offset += count( $objects );
+ }
+ }
+
+ /**
+ * Remove an object from the list
+ * @param int $index
+ * @return CSSObject The removed object
+ */
+ public function remove( $index ) {
+ if ( $index < 0 || $index >= count( $this->objects ) ) {
+ throw new \OutOfBoundsException( 'Index is out of range.' );
+ }
+ $ret = $this->objects[$index];
+ array_splice( $this->objects, $index, 1 );
+
+ // This works most sanely with foreach() and removing the current index
+ if ( $this->offset >= $index ) {
+ $this->offset--;
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Extract a slice of the list
+ * @param int $offset
+ * @param int|null $length
+ * @return CSSObject[] The objects in the slice
+ */
+ public function slice( $offset, $length = null ) {
+ return array_slice( $this->objects, $offset, $length );
+ }
+
+ /**
+ * Clear the list
+ */
+ public function clear() {
+ $this->objects = [];
+ $this->offset = 0;
+ }
+
+ // \Countable interface
+
+ public function count() {
+ return count( $this->objects );
+ }
+
+ // \SeekableIterator interface
+
+ public function seek( $offset ) {
+ if ( $offset < 0 || $offset >= count( $this->objects ) ) {
+ throw new \OutOfBoundsException( 'Offset is out of range.' );
+ }
+ $this->offset = $offset;
+ }
+
+ public function current() {
+ return isset( $this->objects[$this->offset] ) ? $this->objects[$this->offset] : null;
+ }
+
+ public function key() {
+ return $this->offset;
+ }
+
+ public function next() {
+ $this->offset++;
+ }
+
+ public function rewind() {
+ $this->offset = 0;
+ }
+
+ public function valid() {
+ return isset( $this->objects[$this->offset] );
+ }
+
+ // \ArrayAccess interface
+
+ public function offsetExists( $offset ) {
+ return isset( $this->objects[$offset] );
+ }
+
+ public function offsetGet( $offset ) {
+ if ( !is_numeric( $offset ) || (float)(int)$offset !== (float)$offset ) {
+ throw new \InvalidArgumentException( 'Offset must be an integer.' );
+ }
+ if ( $offset < 0 || $offset > count( $this->objects ) ) {
+ throw new \OutOfBoundsException( 'Offset is out of range.' );
+ }
+ return $this->objects[$offset];
+ }
+
+ public function offsetSet( $offset, $value ) {
+ if ( !$value instanceof static::$objectType ) {
+ throw new \InvalidArgumentException(
+ static::class . ' may only contain instances of ' . static::$objectType . '.'
+ );
+ }
+ static::testObjects( [ $value ] );
+ if ( !is_numeric( $offset ) || (float)(int)$offset !== (float)$offset ) {
+ throw new \InvalidArgumentException( 'Offset must be an integer.' );
+ }
+ if ( $offset < 0 || $offset > count( $this->objects ) ) {
+ throw new \OutOfBoundsException( 'Offset is out of range.' );
+ }
+ $this->objects[$offset] = $value;
+ }
+
+ public function offsetUnset( $offset ) {
+ if ( isset( $this->objects[$offset] ) && $offset !== count( $this->objects ) - 1 ) {
+ throw new \OutOfBoundsException( 'Cannot leave holes in the list.' );
+ }
+ unset( $this->objects[$offset] );
+ }
+
+ // CSSObject interface
+
+ public function getPosition() {
+ $ret = null;
+ foreach ( $this->objects as $obj ) {
+ $pos = $obj->getPosition();
+ if ( $pos[0] >= 0 && (
+ !$ret || $pos[0] < $ret[0] || $pos[0] === $ret[0] && $pos[1] < $ret[1]
+ ) ) {
+ $ret = $pos;
+ }
+ }
+ return $ret ?: [ -1, -1 ];
+ }
+
+ /**
+ * Return the tokens to use to separate list items
+ * @param CSSObject $left
+ * @param CSSObject|null $right
+ * @return Token[]
+ */
+ protected function getSeparator( CSSObject $left, CSSObject $right = null ) {
+ return [];
+ }
+
+ /**
+ * @param string $function Function to call, toTokenArray() or toComponentValueArray()
+ */
+ private function toTokenOrCVArray( $function ) {
+ $ret = [];
+ $l = count( $this->objects );
+ for ( $i = 0; $i < $l; $i++ ) {
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ foreach ( $this->objects[$i]->$function() as $v ) {
+ $ret[] = $v;
+ }
+ $sep = $this->getSeparator( $this->objects[$i], $i + 1 < $l ? $this->objects[$i + 1] : null );
+ foreach ( $sep as $v ) {
+ $ret[] = $v;
+ }
+ }
+ return $ret;
+ }
+
+ public function toTokenArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function toComponentValueArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValue.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValue.php
new file mode 100644
index 000000000..d22f520a1
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValue.php
@@ -0,0 +1,28 @@
+line, $this->pos ];
+ }
+
+ public function toComponentValueArray() {
+ return [ $this ];
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValueList.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValueList.php
new file mode 100644
index 000000000..b149eafaa
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValueList.php
@@ -0,0 +1,34 @@
+type() : 'n/a';
+ switch ( $type ) {
+ case Token::T_FUNCTION:
+ case Token::T_LEFT_BRACKET:
+ case Token::T_LEFT_PAREN:
+ case Token::T_LEFT_BRACE:
+ throw new \InvalidArgumentException(
+ static::class . " may not contain tokens of type \"$type\"."
+ );
+ }
+ }
+ }
+
+ // Much simpler
+ public function toComponentValueArray() {
+ return $this->objects;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/Declaration.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/Declaration.php
new file mode 100644
index 000000000..bdee3036f
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/Declaration.php
@@ -0,0 +1,123 @@
+type() !== Token::T_IDENT ) {
+ throw new \InvalidArgumentException(
+ "Declaration must begin with an ident token, got {$token->type()}"
+ );
+ }
+
+ list( $this->line, $this->pos ) = $token->getPosition();
+ $this->name = $token->value();
+ $this->value = new ComponentValueList();
+ }
+
+ public function __clone() {
+ $this->value = clone( $this->value );
+ }
+
+ /**
+ * Get the position of this Declaration in the input stream
+ * @return array [ $line, $pos ]
+ */
+ public function getPosition() {
+ return [ $this->line, $this->pos ];
+ }
+
+ /**
+ * Return the declaration's name
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Return the declaration's value
+ * @return ComponentValueList
+ */
+ public function getValue() {
+ return $this->value;
+ }
+
+ /**
+ * Return the declaration's 'important' flag
+ * @return bool
+ */
+ public function getImportant() {
+ return $this->important;
+ }
+
+ /**
+ * Set the 'important' flag
+ * @param bool $flag
+ */
+ public function setImportant( $flag ) {
+ $this->important = (bool)$flag;
+ }
+
+ /**
+ * @param string $function Function to call, toTokenArray() or toComponentValueArray()
+ */
+ private function toTokenOrCVArray( $function ) {
+ $ret = [];
+
+ $ret[] = new Token(
+ Token::T_IDENT,
+ [ 'value' => $this->name, 'position' => [ $this->line, $this->pos ] ]
+ );
+ $ret[] = $v = new Token( Token::T_COLON );
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ foreach ( $this->value->$function() as $v ) {
+ $ret[] = $v;
+ }
+ if ( $this->important ) {
+ if ( !$v instanceof Token || $v->type() !== Token::T_WHITESPACE ) {
+ $ret[] = new Token( Token::T_WHITESPACE, [ 'significant' => false ] );
+ }
+ $ret[] = new Token( Token::T_DELIM, '!' );
+ $ret[] = new Token( Token::T_IDENT, 'important' );
+ }
+ return $ret;
+ }
+
+ public function toTokenArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function toComponentValueArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationList.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationList.php
new file mode 100644
index 000000000..123809286
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationList.php
@@ -0,0 +1,25 @@
+ false ] ),
+ ];
+ } else {
+ return [ new Token( Token::T_SEMICOLON, [ 'significant' => false ] ) ];
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationOrAtRule.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationOrAtRule.php
new file mode 100644
index 000000000..dbd3c8203
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationOrAtRule.php
@@ -0,0 +1,14 @@
+ (bool)$right ] );
+ }
+ if ( $right ) {
+ $ret[] = new Token( Token::T_WHITESPACE, [ 'significant' => false ] );
+ }
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/QualifiedRule.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/QualifiedRule.php
new file mode 100644
index 000000000..d6e708aef
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/QualifiedRule.php
@@ -0,0 +1,87 @@
+prelude = new ComponentValueList();
+ $this->block = SimpleBlock::newFromDelimiter( Token::T_LEFT_BRACE );
+ }
+
+ public function __clone() {
+ $this->prelude = clone( $this->prelude );
+ $this->block = clone( $this->block );
+ }
+
+ /**
+ * Return the rule's prelude
+ * @return ComponentValueList
+ */
+ public function getPrelude() {
+ return $this->prelude;
+ }
+
+ /**
+ * Return the rule's block
+ * @return SimpleBlock
+ */
+ public function getBlock() {
+ return $this->block;
+ }
+
+ /**
+ * Set the block
+ * @param SimpleBlock $block
+ */
+ public function setBlock( SimpleBlock $block = null ) {
+ if ( $block->getStartTokenType() !== Token::T_LEFT_BRACE ) {
+ throw new \InvalidArgumentException( 'Qualified rule block must be delimited by {}' );
+ }
+ $this->block = $block;
+ }
+
+ /**
+ * @param string $function Function to call, toTokenArray() or toComponentValueArray()
+ */
+ private function toTokenOrCVArray( $function ) {
+ $ret = [];
+
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ foreach ( $this->prelude->$function() as $v ) {
+ $ret[] = $v;
+ }
+ foreach ( $this->block->$function() as $v ) {
+ $ret[] = $v;
+ }
+ return $ret;
+ }
+
+ public function toTokenArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function toComponentValueArray() {
+ return $this->toTokenOrCVArray( __FUNCTION__ );
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/Rule.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/Rule.php
new file mode 100644
index 000000000..cffe8cc0f
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/Rule.php
@@ -0,0 +1,33 @@
+line, $this->pos ) = $token->getPosition();
+ }
+
+ /**
+ * Get the position of this Declaration in the input stream
+ * @return array [ $line, $pos ]
+ */
+ public function getPosition() {
+ return [ $this->line, $this->pos ];
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/RuleList.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/RuleList.php
new file mode 100644
index 000000000..6e2fa2ce4
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/RuleList.php
@@ -0,0 +1,18 @@
+ false ] ) ] : [];
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/SimpleBlock.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/SimpleBlock.php
new file mode 100644
index 000000000..c5eda60ac
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/SimpleBlock.php
@@ -0,0 +1,125 @@
+endTokenType = static::matchingDelimiter( $token->type() );
+ if ( $this->endTokenType === null ) {
+ throw new \InvalidArgumentException(
+ 'A SimpleBlock is delimited by either {}, [], or ().'
+ );
+ }
+
+ list( $this->line, $this->pos ) = $token->getPosition();
+ $this->startTokenType = $token->type();
+ $this->value = new ComponentValueList();
+ }
+
+ public function __clone() {
+ $this->value = clone( $this->value );
+ }
+
+ /**
+ * Create simple block by token type
+ * @param string $delimiter Token::T_LEFT_PAREN, Token::T_LEFT_BRACE, or
+ * Token::T_LEFT_BRACKET
+ * @return SimpleBlock
+ */
+ public static function newFromDelimiter( $delimiter ) {
+ return new static( new Token( $delimiter ) );
+ }
+
+ /**
+ * Return the ending delimiter for a starting delimiter
+ * @param string Token::T_* constant
+ * @return string|null Matching Token::T_* constant, if any
+ */
+ public static function matchingDelimiter( $delim ) {
+ switch ( $delim ) {
+ case Token::T_LEFT_BRACE:
+ return Token::T_RIGHT_BRACE;
+ case Token::T_LEFT_BRACKET:
+ return Token::T_RIGHT_BRACKET;
+ case Token::T_LEFT_PAREN:
+ return Token::T_RIGHT_PAREN;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Get the start token type
+ * @return string
+ */
+ public function getStartTokenType() {
+ return $this->startTokenType;
+ }
+
+ /**
+ * Get the end token type
+ * @return string
+ */
+ public function getEndTokenType() {
+ return $this->endTokenType;
+ }
+
+ /**
+ * Return the block's value
+ * @return ComponentValueList
+ */
+ public function getValue() {
+ return $this->value;
+ }
+
+ public function toTokenArray() {
+ $ret = [
+ new Token( $this->startTokenType, [ 'position' => [ $this->line, $this->pos ] ] ),
+ ];
+
+ // Manually looping and appending turns out to be noticably faster than array_merge.
+ $tokens = $this->value->toTokenArray();
+ if ( $tokens && $this->startTokenType === Token::T_LEFT_BRACE ) {
+ if ( $tokens[0]->type() !== Token::T_WHITESPACE ) {
+ $ret[] = new Token( Token::T_WHITESPACE, [ 'significant' => false ] );
+ }
+ foreach ( $tokens as $v ) {
+ $ret[] = $v;
+ }
+ if ( $tokens[count( $tokens ) - 1]->type() !== Token::T_WHITESPACE ) {
+ $ret[] = new Token( Token::T_WHITESPACE, [ 'significant' => false ] );
+ }
+ } else {
+ foreach ( $tokens as $v ) {
+ $ret[] = $v;
+ }
+ }
+
+ $ret[] = new Token( $this->endTokenType );
+
+ return $ret;
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/Stylesheet.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/Stylesheet.php
new file mode 100644
index 000000000..e7b048fcb
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/Stylesheet.php
@@ -0,0 +1,59 @@
+ruleList = $rules ?: new RuleList();
+ }
+
+ public function __clone() {
+ $this->ruleList = clone( $this->ruleList );
+ }
+
+ /**
+ * @return RuleList
+ */
+ public function getRuleList() {
+ return $this->ruleList;
+ }
+
+ public function getPosition() {
+ // Stylesheets don't really have a position
+ return [ 0, 0 ];
+ }
+
+ public function toTokenArray() {
+ return $this->ruleList->toTokenArray();
+ }
+
+ public function toComponentValueArray() {
+ return $this->ruleList->toComponentValueArray();
+ }
+
+ public function __toString() {
+ return Util::stringify( $this );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/Token.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/Token.php
new file mode 100644
index 000000000..afbb02832
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/Token.php
@@ -0,0 +1,568 @@
+ $value ];
+ }
+
+ if ( isset( $value['position'] ) ) {
+ if ( !is_array( $value['position'] ) || count( $value['position'] ) !== 2 ) {
+ throw new \InvalidArgumentException( 'Position must be an array of two integers' );
+ }
+ list( $this->line, $this->pos ) = $value['position'];
+ if ( !is_int( $this->line ) || !is_int( $this->pos ) ) {
+ throw new \InvalidArgumentException( 'Position must be an array of two integers' );
+ }
+ }
+ if ( isset( $value['significant'] ) ) {
+ $this->significant = (bool)$value['significant'];
+ }
+
+ $this->type = $type;
+ switch ( $type ) {
+ case self::T_IDENT:
+ case self::T_FUNCTION:
+ case self::T_AT_KEYWORD:
+ case self::T_STRING:
+ case self::T_URL:
+ if ( !isset( $value['value'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a value" );
+ }
+ $this->value = (string)$value['value'];
+ break;
+
+ case self::T_HASH:
+ if ( !isset( $value['value'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a value" );
+ }
+ if ( !isset( $value['typeFlag'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a typeFlag" );
+ }
+ if ( !in_array( $value['typeFlag'], [ 'id', 'unrestricted' ], true ) ) {
+ throw new \InvalidArgumentException( "Invalid type flag for Token type $this->type" );
+ }
+ $this->value = (string)$value['value'];
+ $this->typeFlag = $value['typeFlag'];
+ break;
+
+ case self::T_DELIM:
+ if ( !isset( $value['value'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a value" );
+ }
+ $this->value = (string)$value['value'];
+ if ( mb_strlen( $this->value, 'UTF-8' ) !== 1 ) {
+ throw new \InvalidArgumentException(
+ "Value for Token type $this->type must be a single character"
+ );
+ }
+ break;
+
+ case self::T_NUMBER:
+ case self::T_PERCENTAGE:
+ case self::T_DIMENSION:
+ if ( !isset( $value['value'] ) ||
+ !is_numeric( $value['value'] ) || !is_finite( $value['value'] )
+ ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a numeric value" );
+ }
+ if ( !isset( $value['typeFlag'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a typeFlag" );
+ }
+ $this->typeFlag = $value['typeFlag'];
+ if ( $this->typeFlag === 'integer' ) {
+ $this->value = (int)$value['value'];
+ if ( (float)$this->value !== (float)$value['value'] ) {
+ throw new \InvalidArgumentException(
+ "typeFlag is 'integer', but value supplied is not an integer"
+ );
+ }
+ } elseif ( $this->typeFlag === 'number' ) {
+ $this->value = (float)$value['value'];
+ } else {
+ throw new \InvalidArgumentException( "Invalid type flag for Token type $this->type" );
+ }
+
+ if ( isset( $value['representation'] ) ) {
+ if ( !is_numeric( $value['representation'] ) ) {
+ throw new \InvalidArgumentException( 'Representation must be numeric' );
+ }
+ $this->representation = $value['representation'];
+ if ( (float)$this->representation !== (float)$this->value ) {
+ throw new \InvalidArgumentException(
+ "Representation \"$this->representation\" does not match value \"$this->value\""
+ );
+ }
+ }
+
+ if ( $type === self::T_DIMENSION ) {
+ if ( !isset( $value['unit'] ) ) {
+ throw new \InvalidArgumentException( "Token type $this->type requires a unit" );
+ }
+ $this->unit = $value['unit'];
+ }
+ break;
+
+ case self::T_UNICODE_RANGE:
+ if ( !isset( $value['start'] ) || !is_int( $value['start'] ) ) {
+ throw new \InvalidArgumentException(
+ "Token type $this->type requires a starting code point as an integer"
+ );
+ }
+ $this->start = $value['start'];
+ if ( !isset( $value['end'] ) ) {
+ $this->end = $this->start;
+ } elseif ( !is_int( $value['end'] ) ) {
+ throw new \InvalidArgumentException( 'Ending code point must be an integer' );
+ } else {
+ $this->end = $value['end'];
+ }
+ break;
+
+ case self::T_BAD_STRING:
+ case self::T_BAD_URL:
+ case self::T_INCLUDE_MATCH:
+ case self::T_DASH_MATCH:
+ case self::T_PREFIX_MATCH:
+ case self::T_SUFFIX_MATCH:
+ case self::T_SUBSTRING_MATCH:
+ case self::T_COLUMN:
+ case self::T_WHITESPACE:
+ case self::T_CDO:
+ case self::T_CDC:
+ case self::T_COLON:
+ case self::T_SEMICOLON:
+ case self::T_COMMA:
+ case self::T_LEFT_BRACKET:
+ case self::T_RIGHT_BRACKET:
+ case self::T_LEFT_PAREN:
+ case self::T_RIGHT_PAREN:
+ case self::T_LEFT_BRACE:
+ case self::T_RIGHT_BRACE:
+ break;
+
+ case self::T_EOF:
+ // Let EOF have a typeFlag of 'recursion-depth-exceeded', used
+ // to avoid cascading errors when that occurs.
+ if ( isset( $value['typeFlag'] ) && $value['typeFlag'] !== '' ) {
+ $this->typeFlag = $value['typeFlag'];
+ if ( $this->typeFlag !== 'recursion-depth-exceeded' ) {
+ throw new \InvalidArgumentException( "Invalid type flag for Token type $this->type" );
+ }
+ }
+ break;
+
+ default:
+ throw new \InvalidArgumentException( "Unknown token type \"$this->type\"." );
+ }
+ }
+
+ /**
+ * Get the type of this token
+ * @return string One of the Token::T_* constants
+ */
+ public function type() {
+ return $this->type;
+ }
+
+ /**
+ * Get the value of this token
+ * @return string|int|float $value
+ */
+ public function value() {
+ return $this->value;
+ }
+
+ /**
+ * Get the type flag for this T_HASH or numeric token
+ * @return string
+ */
+ public function typeFlag() {
+ return $this->typeFlag;
+ }
+
+ /**
+ * Get the representation for this numeric token
+ * @return string|null
+ */
+ public function representation() {
+ return $this->representation;
+ }
+
+ /**
+ * Get the unit for this T_DIMENSION token
+ * @return string
+ */
+ public function unit() {
+ return $this->unit;
+ }
+
+ /**
+ * Get the unicode range for this T_UNICODE_RANGE token
+ * @return array [ int $start, int $end ]
+ */
+ public function range() {
+ return [ $this->start, $this->end ];
+ }
+
+ /**
+ * Whether this token is considered "significant"
+ *
+ * A token that isn't "significant" may be removed for minification of CSS.
+ * For example, most whitespace is entirely optional, as is the semicolon
+ * after the last declaration in a block.
+ *
+ * @return bool
+ */
+ public function significant() {
+ return $this->significant;
+ }
+
+ /**
+ * Make a copy of this token with altered "significant" flag
+ * @param bool $significant Whether the new token is considered "significant"
+ * @return Token May be the same as the current token
+ */
+ public function copyWithSignificance( $significant ) {
+ $significant = (bool)$significant;
+ if ( $significant === $this->significant ) {
+ return $this;
+ }
+ $ret = clone( $this );
+ $ret->significant = $significant;
+ return $ret;
+ }
+
+ public function toTokenArray() {
+ return [ $this ];
+ }
+
+ public function toComponentValueArray() {
+ switch ( $this->type ) {
+ case self::T_FUNCTION:
+ case self::T_LEFT_BRACKET:
+ case self::T_LEFT_PAREN:
+ case self::T_LEFT_BRACE:
+ throw new \UnexpectedValueException(
+ "Token type \"$this->type\" is not valid in a ComponentValueList."
+ );
+
+ default:
+ return [ $this ];
+ }
+ }
+
+ /**
+ * Escape an ident-like string
+ * @param string $s
+ * @return string
+ */
+ private static function escapeIdent( $s ) {
+ return preg_replace_callback(
+ '/
+ [^a-zA-Z0-9_\-\x{80}-\x{10ffff}] # Characters that are never allowed
+ | (?:^|(?<=^-))[0-9] # Digits are not allowed at the start of an identifier
+ | (?<=^-)- # Two dashes are not allowed at the start of an identifier
+ /ux',
+ function ( $m ) {
+ if ( $m[0] === "\n" || ctype_xdigit( $m[0] ) ) {
+ return sprintf( '\\%x ', ord( $m[0] ) );
+ }
+ return '\\' . $m[0];
+ },
+ $s
+ );
+ }
+
+ public function __toString() {
+ switch ( $this->type ) {
+ case self::T_IDENT:
+ return self::escapeIdent( $this->value );
+
+ case self::T_FUNCTION:
+ return self::escapeIdent( $this->value ) . '(';
+
+ case self::T_AT_KEYWORD:
+ return '@' . self::escapeIdent( $this->value );
+
+ case self::T_HASH:
+ if ( $this->typeFlag === 'id' ) {
+ return '#' . self::escapeIdent( $this->value );
+ } else {
+ return '#' . preg_replace_callback( '/[^a-zA-Z0-9_\-\x{80}-\x{10ffff}]/u', function ( $m ) {
+ return $m[0] === "\n" ? '\\a ' : '\\' . $m[0];
+ }, $this->value );
+ }
+
+ case self::T_STRING:
+ // We could try to decide whether single or double quote is
+ // better, but it doesn't seem worth the effort.
+ return '"' . strtr( $this->value, [
+ '"' => '\\"',
+ '\\' => '\\\\',
+ "\n" => '\\a ',
+ ] ) . '"';
+
+ case self::T_URL:
+ // We could try to decide whether single or double quote is
+ // better, but it doesn't seem worth the effort.
+ return 'url("' . strtr( $this->value, [
+ '"' => '\\"',
+ '\\' => '\\\\',
+ "\n" => '\\a ',
+ ] ) . '")';
+
+ case self::T_BAD_STRING:
+ // It's supposed to round trip, so...
+ // (this is really awful because we can't close it)
+ return "'badstring\n";
+
+ case self::T_BAD_URL:
+ // It's supposed to round trip, so...
+ return "url(badurl'')";
+
+ case self::T_DELIM:
+ if ( $this->value === '\\' ) {
+ return "\\\n";
+ }
+ return $this->value;
+
+ case self::T_NUMBER:
+ case self::T_PERCENTAGE:
+ case self::T_DIMENSION:
+ if ( $this->representation !== null && (float)$this->representation === (float)$this->value ) {
+ $number = $this->representation;
+ } elseif ( $this->typeFlag === 'integer' ) {
+ $number = sprintf( '%d', $this->value );
+ } else {
+ $number = sprintf( '%.15g', $this->value );
+ }
+
+ if ( $this->type === self::T_PERCENTAGE ) {
+ $unit = '%';
+ } elseif ( $this->type === self::T_DIMENSION ) {
+ $unit = self::escapeIdent( $this->unit );
+ if ( strpos( $number, 'e' ) === false && strpos( $number, 'E' ) === false &&
+ preg_match( '/^[eE][+-]?\d/', $unit )
+ ) {
+ // Unit would look like exponential notation, so escape the leading "e"
+ $unit = sprintf( '\\%x ', ord( $unit[0] ) ) . substr( $unit, 1 );
+ }
+ } else {
+ $unit = '';
+ }
+
+ return $number . $unit;
+
+ case self::T_UNICODE_RANGE:
+ if ( $this->start === 0 && $this->end === 0xffffff ) {
+ return 'U+??????';
+ }
+ $fmt = 'U+%x';
+ for ( $b = 0; $b < 24; $b += 4, $fmt .= '?' ) {
+ $mask = ( 1 << $b ) - 1;
+ if (
+ ( $this->start & $mask ) === 0 &&
+ ( $this->end & $mask ) === $mask &&
+ ( $this->start & ~$mask ) === ( $this->end & ~$mask )
+ ) {
+ return sprintf( $fmt, $this->start >> $b );
+ }
+ }
+ return sprintf( 'U+%x-%x', $this->start, $this->end );
+
+ case self::T_INCLUDE_MATCH:
+ return '~=';
+
+ case self::T_DASH_MATCH:
+ return '|=';
+
+ case self::T_PREFIX_MATCH:
+ return '^=';
+
+ case self::T_SUFFIX_MATCH:
+ return '$=';
+
+ case self::T_SUBSTRING_MATCH:
+ return '*=';
+
+ case self::T_COLUMN:
+ return '||';
+
+ case self::T_WHITESPACE:
+ return ' ';
+
+ case self::T_CDO:
+ return '';
+
+ case self::T_COLON:
+ return ':';
+
+ case self::T_SEMICOLON:
+ return ';';
+
+ case self::T_COMMA:
+ return ',';
+
+ case self::T_LEFT_BRACKET:
+ case self::T_RIGHT_BRACKET:
+ case self::T_LEFT_PAREN:
+ case self::T_RIGHT_PAREN:
+ case self::T_LEFT_BRACE:
+ case self::T_RIGHT_BRACE:
+ return $this->type;
+
+ case self::T_EOF:
+ return '';
+
+ default:
+ throw new \UnexpectedValueException( "Unknown token type \"$this->type\"." );
+ }
+ }
+
+ /**
+ * Indicate whether the two tokens need to be separated
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#serialization
+ * @param Token $firstToken
+ * @param Token $secondToken
+ * @return bool
+ */
+ public static function separate( Token $firstToken, Token $secondToken ) {
+ // Keys are the row headings, values are the columns that have an ✗
+ static $sepTable = [
+ self::T_IDENT => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE, self::T_CDC, self::T_LEFT_PAREN
+ ],
+ self::T_AT_KEYWORD => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE, self::T_CDC
+ ],
+ self::T_HASH => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE, self::T_CDC
+ ],
+ self::T_DIMENSION => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE, self::T_CDC
+ ],
+ '#' => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE
+ ],
+ '-' => [
+ // Add '-' here from Editor's Draft, to go with the draft's
+ // adding of tokens beginning with "--" that we also picked up.
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE
+ ],
+ self::T_NUMBER => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, self::T_NUMBER,
+ self::T_PERCENTAGE, self::T_DIMENSION, self::T_UNICODE_RANGE
+ ],
+ '@' => [
+ self::T_IDENT, self::T_FUNCTION, self::T_URL, self::T_BAD_URL, '-', self::T_UNICODE_RANGE
+ ],
+ self::T_UNICODE_RANGE => [
+ self::T_IDENT, self::T_FUNCTION, self::T_NUMBER, self::T_PERCENTAGE, self::T_DIMENSION, '?'
+ ],
+ '.' => [ self::T_NUMBER, self::T_PERCENTAGE, self::T_DIMENSION ],
+ '+' => [ self::T_NUMBER, self::T_PERCENTAGE, self::T_DIMENSION ],
+ '$' => [ '=' ],
+ '*' => [ '=' ],
+ '^' => [ '=' ],
+ '~' => [ '=' ],
+ '|' => [ '=', '|' ],
+ '/' => [ '*' ],
+ ];
+
+ $t1 = $firstToken->type === Token::T_DELIM ? $firstToken->value : $firstToken->type;
+ $t2 = $secondToken->type === Token::T_DELIM ? $secondToken->value : $secondToken->type;
+
+ return isset( $sepTable[$t1] ) && in_array( $t2, $sepTable[$t1], true );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Objects/TokenList.php b/lib/css-sanitizer/Wikimedia/CSS/Objects/TokenList.php
new file mode 100644
index 000000000..1e2873711
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Objects/TokenList.php
@@ -0,0 +1,33 @@
+objects;
+ }
+
+ // This one, though, is complicated
+ public function toComponentValueArray() {
+ $parser = Parser::newFromTokens( $this->objects );
+ $ret = $parser->parseComponentValueList();
+ if ( $parser->getParseErrors() ) {
+ $ex = new \UnexpectedValueException( 'TokenList cannot be converted to a ComponentValueList' );
+ $ex->parseErrors = $parser->getParseErrors();
+ throw $ex;
+ }
+ return $ret->toComponentValueArray();
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/DataSource.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/DataSource.php
new file mode 100644
index 000000000..c4aadb1d6
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/DataSource.php
@@ -0,0 +1,29 @@
+source = $source;
+ }
+
+ /**
+ * Read a character from the data source
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#input-preprocessing
+ * @return string One UTF-8 character, or empty string on EOF
+ */
+ protected function nextChar() {
+ $char = $this->source->readCharacter();
+
+ // Perform transformations per the spec
+
+ // Any U+0000 becomes U+FFFD
+ if ( $char === "\0" ) {
+ return \UtfNormal\Constants::UTF8_REPLACEMENT;
+ }
+
+ // Any U+000D, U+000C, or pair of U+000D + U+000A becomes U+000A
+ if ( $char === "\f" ) { // U+000C
+ return "\n";
+ }
+
+ if ( $char === "\r" ) { // Either U+000D + U+000A or a lone U+000D
+ $char2 = $this->source->readCharacter();
+ if ( $char2 !== "\n" ) {
+ $this->source->putBackCharacter( $char2 );
+ }
+ return "\n";
+ }
+
+ return $char;
+ }
+
+ /**
+ * Update the current and next character fields
+ */
+ protected function consumeCharacter() {
+ if ( $this->currentCharacter === "\n" ) {
+ $this->line++;
+ $this->pos = 1;
+ } elseif ( $this->currentCharacter !== DataSource::EOF ) {
+ $this->pos++;
+ }
+
+ $this->currentCharacter = $this->nextChar();
+ $this->nextCharacter = $this->nextChar();
+ $this->source->putBackCharacter( $this->nextCharacter );
+ }
+
+ /**
+ * Reconsume the next character
+ *
+ * In more normal terms, this pushes a character back onto the data source
+ * so it will be read again for the next call to self::consumeCharacter().
+ */
+ protected function reconsumeCharacter() {
+ // @codeCoverageIgnoreStart
+ if ( !is_string( $this->currentCharacter ) ) {
+ throw new \UnexpectedValueException( "[$this->line:$this->pos] Can't reconsume" );
+ }
+ // @codeCoverageIgnoreEnd
+
+ if ( $this->currentCharacter === DataSource::EOF ) {
+ // Huh?
+ return;
+ }
+
+ $this->source->putBackCharacter( $this->currentCharacter );
+ $this->nextCharacter = $this->currentCharacter;
+ $this->currentCharacter = (object)[];
+ $this->pos--;
+ }
+
+ /**
+ * Look ahead at the next three characters
+ * @return string[] Three characters
+ */
+ protected function lookAhead() {
+ $ret = [
+ $this->nextChar(),
+ $this->nextChar(),
+ $this->nextChar(),
+ ];
+ $this->source->putBackCharacter( $ret[2] );
+ $this->source->putBackCharacter( $ret[1] );
+ $this->source->putBackCharacter( $ret[0] );
+
+ return $ret;
+ }
+
+ public function getParseErrors() {
+ return $this->parseErrors;
+ }
+
+ public function clearParseErrors() {
+ $this->parseErrors = [];
+ }
+
+ /**
+ * Record a parse error
+ * @param string $tag Error tag
+ * @param array|null $position Report the error as starting at this
+ * position instead of at the current position.
+ * @param array $data Extra data about the error.
+ */
+ protected function parseError( $tag, array $position = null, array $data = [] ) {
+ if ( $position ) {
+ if ( isset( $position['position'] ) ) {
+ $position = $position['position'];
+ }
+ if ( count( $position ) !== 2 || !is_int( $position[0] ) || !is_int( $position[1] ) ) {
+ // @codeCoverageIgnoreStart
+ throw new InvalidArgumentException( 'Invalid position' );
+ // @codeCoverageIgnoreEnd
+ }
+ $err = [ $tag, $position[0], $position[1] ];
+ } else {
+ $err = [ $tag, $this->line, $this->pos ];
+ }
+ $this->parseErrors[] = array_merge( $err, $data );
+ }
+
+ /**
+ * Read a token from the data source
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-token
+ * @return Token
+ */
+ public function consumeToken() {
+ $this->consumeCharacter();
+ $pos = [ 'position' => [ $this->line, $this->pos ] ];
+
+ switch ( (string)$this->currentCharacter ) {
+ case "\n":
+ case "\t":
+ case ' ':
+ // Whitespace token
+ while ( self::isWhitespace( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ }
+ return new Token( Token::T_WHITESPACE, $pos );
+
+ case '"':
+ case '\'':
+ // String token
+ return $this->consumeStringToken( $this->currentCharacter, $pos );
+
+ case '#':
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::isNameCharacter( $this->nextCharacter ) ||
+ self::isValidEscape( $next, $next2 )
+ ) {
+ return new Token( Token::T_HASH, $pos + [
+ 'typeFlag' => self::wouldStartIdentifier( $next, $next2, $next3 ) ? 'id' : 'unrestricted',
+ 'value' => $this->consumeName(),
+ ] );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '$':
+ if ( $this->nextCharacter === '=' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_SUFFIX_MATCH, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '(':
+ return new Token( Token::T_LEFT_PAREN, $pos );
+
+ case ')':
+ return new Token( Token::T_RIGHT_PAREN, $pos );
+
+ case '*':
+ if ( $this->nextCharacter === '=' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_SUBSTRING_MATCH, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '+':
+ case '.':
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::wouldStartNumber( $this->currentCharacter, $next, $next2 ) ) {
+ $this->reconsumeCharacter();
+ return $this->consumeNumericToken( $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case ',':
+ return new Token( Token::T_COMMA, $pos );
+
+ case '-':
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::wouldStartNumber( $this->currentCharacter, $next, $next2 ) ) {
+ $this->reconsumeCharacter();
+ return $this->consumeNumericToken( $pos );
+ }
+
+ if ( $next === '-' && $next2 === '>' ) {
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ return new Token( Token::T_CDC, $pos );
+ }
+
+ if ( self::wouldStartIdentifier( $this->currentCharacter, $next, $next2 ) ) {
+ $this->reconsumeCharacter();
+ return $this->consumeIdentLikeToken( $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '/':
+ if ( $this->nextCharacter === '*' ) {
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ while ( $this->currentCharacter !== DataSource::EOF &&
+ !( $this->currentCharacter === '*' && $this->nextCharacter === '/' )
+ ) {
+ $this->consumeCharacter();
+ }
+ if ( $this->currentCharacter === DataSource::EOF ) {
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'unclosed-comment', $pos );
+ }
+ $this->consumeCharacter();
+ return $this->consumeToken();
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case ':':
+ return new Token( Token::T_COLON, $pos );
+
+ case ';':
+ return new Token( Token::T_SEMICOLON, $pos );
+
+ case '<':
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( $next === '!' && $next2 === '-' && $next3 === '-' ) {
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ return new Token( Token::T_CDO, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '@':
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::wouldStartIdentifier( $next, $next2, $next3 ) ) {
+ return new Token( Token::T_AT_KEYWORD, $pos + [ 'value' => $this->consumeName() ] );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '[':
+ return new Token( Token::T_LEFT_BRACKET, $pos );
+
+ case '\\':
+ if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
+ $this->reconsumeCharacter();
+ return $this->consumeIdentLikeToken( $pos );
+ }
+
+ $this->parseError( 'bad-escape' );
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case ']':
+ return new Token( Token::T_RIGHT_BRACKET, $pos );
+
+ case '^':
+ if ( $this->nextCharacter === '=' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_PREFIX_MATCH, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '{':
+ return new Token( Token::T_LEFT_BRACE, $pos );
+
+ case '}':
+ return new Token( Token::T_RIGHT_BRACE, $pos );
+
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ case '8':
+ case '9':
+ $this->reconsumeCharacter();
+ return $this->consumeNumericToken( $pos );
+
+ case 'u':
+ case 'U':
+ if ( $this->nextCharacter === '+' ) {
+ list( $next, $next2 ) = $this->lookAhead();
+ if ( self::isHexDigit( $next2 ) || $next2 === '?' ) {
+ $this->consumeCharacter();
+ return $this->consumeUnicodeRangeToken( $pos );
+ }
+ }
+
+ $this->reconsumeCharacter();
+ return $this->consumeIdentLikeToken( $pos );
+
+ case '|':
+ if ( $this->nextCharacter === '=' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_DASH_MATCH, $pos );
+ }
+
+ if ( $this->nextCharacter === '|' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_COLUMN, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case '~':
+ if ( $this->nextCharacter === '=' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_INCLUDE_MATCH, $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+
+ case DataSource::EOF:
+ return new Token( Token::T_EOF, $pos );
+
+ default:
+ if ( self::isNameStartCharacter( $this->currentCharacter ) ) {
+ $this->reconsumeCharacter();
+ return $this->consumeIdentLikeToken( $pos );
+ }
+
+ return new Token( Token::T_DELIM, $pos + [ 'value' => $this->currentCharacter ] );
+ }
+ }
+
+ /**
+ * Consume a numeric token
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-numeric-token
+ * @param array $data Data for the new token (typically contains just 'position')
+ * @return Token
+ */
+ protected function consumeNumericToken( array $data ) {
+ list( $data['representation'], $data['value'], $data['typeFlag'] ) = $this->consumeNumber();
+
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::wouldStartIdentifier( $next, $next2, $next3 ) ) {
+ return new Token( Token::T_DIMENSION, $data + [ 'unit' => $this->consumeName() ] );
+ } elseif ( $this->nextCharacter === '%' ) {
+ $this->consumeCharacter();
+ return new Token( Token::T_PERCENTAGE, $data );
+ } else {
+ return new Token( Token::T_NUMBER, $data );
+ }
+ }
+
+ /**
+ * Consume an ident-like token
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-an-ident-like-token
+ * @note Per the draft as of January 2017, quoted URLs are parsed as
+ * functions named 'url'. This is needed in order to implement the ``
+ * type in the [Values specification](https://www.w3.org/TR/2016/CR-css-values-3-20160929/#urls).
+ * @param array $data Data for the new token (typically contains just 'position')
+ * @return Token
+ */
+ protected function consumeIdentLikeToken( array $data ) {
+ $name = $this->consumeName();
+
+ if ( $this->nextCharacter === '(' ) {
+ $this->consumeCharacter();
+
+ if ( !strcasecmp( $name, 'url' ) ) {
+ while ( true ) {
+ list( $next, $next2 ) = $this->lookAhead();
+ if ( !self::isWhitespace( $next ) || !self::isWhitespace( $next2 ) ) {
+ break;
+ }
+ $this->consumeCharacter();
+ }
+ if ( $next !== '"' && $next !== '\'' &&
+ !( self::isWhitespace( $next ) && ( $next2 === '"' || $next2=== '\'' ) )
+ ) {
+ return $this->consumeUrlToken( $data );
+ }
+ }
+
+ return new Token( Token::T_FUNCTION, $data + [ 'value' => $name ] );
+ }
+
+ return new Token( Token::T_IDENT, $data + [ 'value' => $name ] );
+ }
+
+ /**
+ * Consume a string token
+ *
+ * This assumes the leading quote or apostrophe has already been consumed.
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-string-token
+ * @param string $endChar Ending character of the string
+ * @param array $data Data for the new token (typically contains just 'position')
+ * @return Token
+ */
+ protected function consumeStringToken( $endChar, array $data ) {
+ $data['value'] = '';
+
+ while ( true ) {
+ $this->consumeCharacter();
+ switch ( $this->currentCharacter ) {
+ case DataSource::EOF:
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'unclosed-string', $data );
+ break 2;
+
+ case $endChar:
+ break 2;
+
+ case "\n":
+ $this->parseError( 'newline-in-string' );
+ $this->reconsumeCharacter();
+ return new Token( Token::T_BAD_STRING, [ 'value' => '' ] + $data );
+
+ case '\\':
+ if ( $this->nextCharacter === DataSource::EOF ) {
+ // Do nothing
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'bad-escape' );
+ } elseif ( $this->nextCharacter === "\n" ) {
+ // Consume it
+ $this->consumeCharacter();
+ } elseif ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
+ $data['value'] .= $this->consumeEscape();
+ } else {
+ // @codeCoverageIgnoreStart
+ throw new \UnexpectedValueException( "[$this->line:$this->pos] Unexpected state" );
+ // @codeCoverageIgnoreEnd
+ }
+ break;
+
+ default:
+ $data['value'] .= $this->currentCharacter;
+ break;
+ }
+ }
+
+ return new Token( Token::T_STRING, $data );
+ }
+
+ /**
+ * Consume a URL token
+ *
+ * This assumes the leading "url(" has already been consumed.
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-url-token
+ * @note Per the draft as of January 2017, this does not handle quoted URL tokens.
+ * @param array $data Data for the new token (typically contains just 'position')
+ * @return Token
+ */
+ protected function consumeUrlToken( array $data ) {
+ // 1.
+ $data['value'] = '';
+
+ // 2.
+ while ( self::isWhitespace( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ }
+
+ // 3.
+ if ( $this->nextCharacter === DataSource::EOF ) {
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'unclosed-url', $data );
+ return new Token( Token::T_URL, $data );
+ }
+
+ // 4. (removed in draft, this was formerly the parsing for a quoted URL token)
+
+ // 5. (renumbered as 4 in the draft)
+ while ( true ) {
+ $this->consumeCharacter();
+ switch ( $this->currentCharacter ) {
+ case DataSource::EOF:
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'unclosed-url', $data );
+ break 2;
+
+ case ')':
+ break 2;
+
+ case "\n":
+ case "\t":
+ case ' ':
+ while ( self::isWhitespace( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ }
+ if ( $this->nextCharacter === ')' ) {
+ $this->consumeCharacter();
+ break 2;
+ } elseif ( $this->nextCharacter === DataSource::EOF ) {
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->consumeCharacter();
+ $this->parseError( 'unclosed-url', $data );
+ break 2;
+ } else {
+ $this->consumeBadUrlRemnants();
+ return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
+ }
+ break;
+
+ case '"':
+ case '\'':
+ case '(':
+ $this->parseError( 'bad-character-in-url' );
+ $this->consumeBadUrlRemnants();
+ return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
+
+ case '\\':
+ if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
+ $data['value'] .= $this->consumeEscape();
+ } else {
+ $this->parseError( 'bad-escape' );
+ $this->consumeBadUrlRemnants();
+ return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
+ }
+ break;
+
+ default:
+ if ( self::isNonPrintable( $this->currentCharacter ) ) {
+ $this->parseError( 'bad-character-in-url' );
+ $this->consumeBadUrlRemnants();
+ return new Token( Token::T_BAD_URL, [ 'value' => '' ] + $data );
+ }
+
+ $data['value'] .= $this->currentCharacter;
+ break;
+ }
+ }
+
+ return new Token( Token::T_URL, $data );
+ }
+
+ /**
+ * Clean up after finding an error in a URL
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-the-remnants-of-a-bad-url
+ */
+ protected function consumeBadUrlRemnants() {
+ while ( true ) {
+ $this->consumeCharacter();
+ if ( $this->currentCharacter === ')' || $this->currentCharacter === DataSource::EOF ) {
+ break;
+ }
+ if ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
+ $this->consumeEscape();
+ }
+ }
+ }
+
+ /**
+ * Consume a unicode-range token
+ *
+ * This assumes the initial "u" has been consumed (currentCharacter is the '+'),
+ * and the next codepoint is verfied to be a hex digit or "?".
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-unicode-range-token
+ * @param array $data Data for the new token (typically contains just 'position')
+ * @return Token
+ */
+ protected function consumeUnicodeRangeToken( array $data ) {
+ // 1.
+ $v = '';
+ while ( strlen( $v ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $v .= $this->currentCharacter;
+ }
+ $anyQ = false;
+ while ( strlen( $v ) < 6 && $this->nextCharacter === '?' ) {
+ $anyQ = true;
+ $this->consumeCharacter();
+ $v .= $this->currentCharacter;
+ }
+
+ if ( $anyQ ) {
+ return new Token( Token::T_UNICODE_RANGE, $data + [
+ 'start' => intval( str_replace( '?', '0', $v ), 16 ),
+ 'end' => intval( str_replace( '?', 'F', $v ), 16 ),
+ ] );
+ }
+
+ $data['start'] = intval( $v, 16 );
+
+ // 2.
+ list( $next, $next2 ) = $this->lookAhead();
+ if ( $next === '-' && self::isHexDigit( $next2 ) ) {
+ $this->consumeCharacter();
+ $v = '';
+ while ( strlen( $v ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $v .= $this->currentCharacter;
+ }
+ $data['end'] = intval( $v, 16 );
+ } else {
+ // 3.
+ $data['end'] = $data['start'];
+ }
+
+ // 4.
+ return new Token( Token::T_UNICODE_RANGE, $data );
+ }
+
+ /**
+ * Indicate if a character is whitespace
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#whitespace
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isWhitespace( $char ) {
+ return $char === "\n" || $char === "\t" || $char === " ";
+ }
+
+ /**
+ * Indicate if a character is a name-start code point
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#name-start-code-point
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isNameStartCharacter( $char ) {
+ // Every non-ASCII character is a name start character, so we can just
+ // check the first byte.
+ $char = ord( $char );
+ return $char >= 0x41 && $char <= 0x5a ||
+ $char >= 0x61 && $char <= 0x7a ||
+ $char >= 0x80 || $char === 0x5f;
+ }
+
+ /**
+ * Indicate if a character is a name code point
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#name-code-point
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isNameCharacter( $char ) {
+ // Every non-ASCII character is a name character, so we can just check
+ // the first byte.
+ $char = ord( $char );
+ return $char >= 0x41 && $char <= 0x5a ||
+ $char >= 0x61 && $char <= 0x7a ||
+ $char >= 0x30 && $char <= 0x39 ||
+ $char >= 0x80 || $char === 0x5f || $char === 0x2d;
+ }
+
+ /**
+ * Indicate if a character is non-printable
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#non-printable-code-point
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isNonPrintable( $char ) {
+ // No non-ASCII character is non-printable, so we can just check the
+ // first byte.
+ $char = ord( $char );
+ return $char >= 0x00 && $char <= 0x08 ||
+ $char === 0x0b ||
+ $char >= 0x0e && $char <= 0x1f ||
+ $char === 0x7f;
+ }
+
+ /**
+ * Indicate if a character is a digit
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#digit
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isDigit( $char ) {
+ // No non-ASCII character is a digit, so we can just check the first
+ // byte.
+ $char = ord( $char );
+ return $char >= 0x30 && $char <= 0x39;
+ }
+
+ /**
+ * Indicate if a character is a hex digit
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#hex-digit
+ * @param string $char A single UTF-8 character
+ * @return bool
+ */
+ protected static function isHexDigit( $char ) {
+ // No non-ASCII character is a hex digit, so we can just check the
+ // first byte.
+ $char = ord( $char );
+ return $char >= 0x30 && $char <= 0x39 ||
+ $char >= 0x41 && $char <= 0x46 ||
+ $char >= 0x61 && $char <= 0x66;
+ }
+
+ /**
+ * Determine if two characters constitute a valid escape
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#starts-with-a-valid-escape
+ * @param string $char1
+ * @param string $char2
+ * @return bool
+ */
+ protected static function isValidEscape( $char1, $char2 ) {
+ return $char1 === '\\' && $char2 !== "\n";
+ }
+
+ /**
+ * Determine if three characters would start an identifier
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#would-start-an-identifier
+ * @param string $char1
+ * @param string $char2
+ * @param string $char3
+ * @return bool
+ */
+ protected static function wouldStartIdentifier( $char1, $char2, $char3 ) {
+ if ( $char1 === '-' ) {
+ // Added the possibility for an itentifier beginning with "--" per the draft.
+ return self::isNameStartCharacter( $char2 ) || $char2 === '-' ||
+ self::isValidEscape( $char2, $char3 );
+ } elseif ( self::isNameStartCharacter( $char1 ) ) {
+ return true;
+ } elseif ( $char1 === '\\' ) {
+ return self::isValidEscape( $char1, $char2 );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Determine if three characters would start a number
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#starts-with-a-number
+ * @param string $char1
+ * @param string $char2
+ * @param string $char3
+ * @return bool
+ */
+ protected static function wouldStartNumber( $char1, $char2, $char3 ) {
+ if ( $char1 === '+' || $char1 === '-' ) {
+ return self::isDigit( $char2 ) ||
+ $char2 === '.' && self::isDigit( $char3 );
+ } elseif ( $char1 === '.' ) {
+ return self::isDigit( $char2 );
+ // @codeCoverageIgnoreStart
+ // Nothing reaches this code
+ } else {
+ return self::isDigit( $char1 );
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ /**
+ * Consume a valid escape
+ *
+ * This assumes the leading backslash is consumed.
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-an-escaped-code-point
+ * @return string Escaped character
+ */
+ protected function consumeEscape() {
+ $position = [ 'position' => [ $this->line, $this->pos ] ];
+
+ $this->consumeCharacter();
+
+ // @codeCoverageIgnoreStart
+ if ( $this->currentCharacter === "\n" ) {
+ throw new \UnexpectedValueException( "[$this->line:$this->pos] Unexpected newline" );
+ }
+ // @codeCoverageIgnoreEnd
+
+ // 1-6 hexits, plus one optional whitespace character
+ if ( self::isHexDigit( $this->currentCharacter ) ) {
+ $num = $this->currentCharacter;
+ while ( strlen( $num ) < 6 && self::isHexDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $num .= $this->currentCharacter;
+ }
+ if ( self::isWhitespace( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ }
+
+ $num = intval( $num, 16 );
+ if ( $num === 0 || $num >= 0xd800 && $num <= 0xdfff || $num > 0x10ffff ) {
+ return \UtfNormal\Constants::UTF8_REPLACEMENT;
+ }
+ return \UtfNormal\Utils::codepointToUtf8( $num );
+ }
+
+ if ( $this->currentCharacter === DataSource::EOF ) {
+ // Parse error from the editor's draft as of 2017-01-06
+ $this->parseError( 'bad-escape', $position );
+ return \UtfNormal\Constants::UTF8_REPLACEMENT;
+ }
+
+ return $this->currentCharacter;
+ }
+
+ /**
+ * Consume a name
+ *
+ * Note this does not do validation on the input stream. Call
+ * self::wouldStartIdentifier() or the like before calling the method if
+ * necessary.
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-name
+ * @return string Name
+ */
+ protected function consumeName() {
+ $name = '';
+
+ while ( true ) {
+ $this->consumeCharacter();
+
+ if ( self::isNameCharacter( $this->currentCharacter ) ) {
+ $name .= $this->currentCharacter;
+ } elseif ( self::isValidEscape( $this->currentCharacter, $this->nextCharacter ) ) {
+ $name .= $this->consumeEscape();
+ } else {
+ $this->reconsumeCharacter(); // Doesn't say to, but breaks otherwise
+ return $name;
+ }
+ }
+ // @codeCoverageIgnoreStart
+ }
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * Consume a number
+ *
+ * Note this does not do validation on the input stream. Call
+ * self::wouldStartNumber() before calling the method if necessary.
+ *
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-number
+ * @return array [ string $value, int|float $number, string $type ('integer' or 'number') ]
+ */
+ protected function consumeNumber() {
+ // 1.
+ $repr = '';
+ $type = 'integer';
+
+ // 2.
+ if ( $this->nextCharacter === '+' || $this->nextCharacter === '-' ) {
+ $this->consumeCharacter();
+ $repr .= $this->currentCharacter;
+ }
+
+ // 3.
+ while ( self::isDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $repr .= $this->currentCharacter;
+ }
+
+ // 4.
+ if ( $this->nextCharacter === '.' ) {
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ if ( self::isDigit( $next2 ) ) {
+ // 4.1.
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ // 4.2.
+ $repr .= $next . $next2;
+ // 4.3.
+ $type = 'number';
+ // 4.4.
+ while ( self::isDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $repr .= $this->currentCharacter;
+ }
+ }
+ }
+
+ // 5.
+ if ( $this->nextCharacter === 'e' || $this->nextCharacter === 'E' ) {
+ list( $next, $next2, $next3 ) = $this->lookAhead();
+ $ok = false;
+ if ( ( $next2 === '+' || $next2 === '-' ) && self::isDigit( $next3 ) ) {
+ $ok = true;
+ // 5.1.
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ // 5.2.
+ $repr .= $next . $next2 . $next3;
+ } elseif ( self::isDigit( $next2 ) ) {
+ $ok = true;
+ // 5.1.
+ $this->consumeCharacter();
+ $this->consumeCharacter();
+ // 5.2.
+ $repr .= $next . $next2;
+ }
+ if ( $ok ) {
+ // 5.3.
+ $type = 'number';
+ // 5.4.
+ while ( self::isDigit( $this->nextCharacter ) ) {
+ $this->consumeCharacter();
+ $repr .= $this->currentCharacter;
+ }
+ }
+ }
+
+ // 6. We assume PHP's casting follows the same rules as
+ // https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#convert-a-string-to-a-number
+ $value = $type === 'integer' ? (int)$repr : (float)$repr;
+
+ // 7.
+ return [ $repr, $value, $type ];
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/Encoder.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/Encoder.php
new file mode 100644
index 000000000..5691d987e
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/Encoder.php
@@ -0,0 +1,330 @@
+ 'UTF-8',
+ 'utf-8' => 'UTF-8',
+ 'utf8' => 'UTF-8',
+ '866' => 'CP866',
+ 'cp866' => 'CP866',
+ 'csibm866' => 'CP866',
+ 'ibm866' => 'CP866',
+ 'csisolatin2' => 'ISO-8859-2',
+ 'iso-8859-2' => 'ISO-8859-2',
+ 'iso-ir-101' => 'ISO-8859-2',
+ 'iso8859-2' => 'ISO-8859-2',
+ 'iso88592' => 'ISO-8859-2',
+ 'iso_8859-2' => 'ISO-8859-2',
+ 'iso_8859-2:1987' => 'ISO-8859-2',
+ 'l2' => 'ISO-8859-2',
+ 'latin2' => 'ISO-8859-2',
+ 'csisolatin3' => 'ISO-8859-3',
+ 'iso-8859-3' => 'ISO-8859-3',
+ 'iso-ir-109' => 'ISO-8859-3',
+ 'iso8859-3' => 'ISO-8859-3',
+ 'iso88593' => 'ISO-8859-3',
+ 'iso_8859-3' => 'ISO-8859-3',
+ 'iso_8859-3:1988' => 'ISO-8859-3',
+ 'l3' => 'ISO-8859-3',
+ 'latin3' => 'ISO-8859-3',
+ 'csisolatin4' => 'ISO-8859-4',
+ 'iso-8859-4' => 'ISO-8859-4',
+ 'iso-ir-110' => 'ISO-8859-4',
+ 'iso8859-4' => 'ISO-8859-4',
+ 'iso88594' => 'ISO-8859-4',
+ 'iso_8859-4' => 'ISO-8859-4',
+ 'iso_8859-4:1988' => 'ISO-8859-4',
+ 'l4' => 'ISO-8859-4',
+ 'latin4' => 'ISO-8859-4',
+ 'csisolatincyrillic' => 'ISO-8859-5',
+ 'cyrillic' => 'ISO-8859-5',
+ 'iso-8859-5' => 'ISO-8859-5',
+ 'iso-ir-144' => 'ISO-8859-5',
+ 'iso8859-5' => 'ISO-8859-5',
+ 'iso88595' => 'ISO-8859-5',
+ 'iso_8859-5' => 'ISO-8859-5',
+ 'iso_8859-5:1988' => 'ISO-8859-5',
+ 'arabic' => 'ISO-8859-6',
+ 'asmo-708' => 'ISO-8859-6',
+ 'csiso88596e' => 'ISO-8859-6',
+ 'csiso88596i' => 'ISO-8859-6',
+ 'csisolatinarabic' => 'ISO-8859-6',
+ 'ecma-114' => 'ISO-8859-6',
+ 'iso-8859-6' => 'ISO-8859-6',
+ 'iso-8859-6-e' => 'ISO-8859-6',
+ 'iso-8859-6-i' => 'ISO-8859-6',
+ 'iso-ir-127' => 'ISO-8859-6',
+ 'iso8859-6' => 'ISO-8859-6',
+ 'iso88596' => 'ISO-8859-6',
+ 'iso_8859-6' => 'ISO-8859-6',
+ 'iso_8859-6:1987' => 'ISO-8859-6',
+ 'csisolatingreek' => 'ISO-8859-7',
+ 'ecma-118' => 'ISO-8859-7',
+ 'elot_928' => 'ISO-8859-7',
+ 'greek' => 'ISO-8859-7',
+ 'greek8' => 'ISO-8859-7',
+ 'iso-8859-7' => 'ISO-8859-7',
+ 'iso-ir-126' => 'ISO-8859-7',
+ 'iso8859-7' => 'ISO-8859-7',
+ 'iso88597' => 'ISO-8859-7',
+ 'iso_8859-7' => 'ISO-8859-7',
+ 'iso_8859-7:1987' => 'ISO-8859-7',
+ 'sun_eu_greek' => 'ISO-8859-7',
+ 'csiso88598e' => 'ISO-8859-8',
+ 'csisolatinhebrew' => 'ISO-8859-8',
+ 'hebrew' => 'ISO-8859-8',
+ 'iso-8859-8' => 'ISO-8859-8',
+ 'iso-8859-8-e' => 'ISO-8859-8',
+ 'iso-ir-138' => 'ISO-8859-8',
+ 'iso8859-8' => 'ISO-8859-8',
+ 'iso88598' => 'ISO-8859-8',
+ 'iso_8859-8' => 'ISO-8859-8',
+ 'iso_8859-8:1988' => 'ISO-8859-8',
+ 'visual' => 'ISO-8859-8',
+ 'csiso88598i' => 'ISO-8859-8', // ISO-8859-8-I?
+ 'iso-8859-8-i' => 'ISO-8859-8', // ISO-8859-8-I?
+ 'logical' => 'ISO-8859-8', // ISO-8859-8-I?
+ 'csisolatin6' => 'ISO-8859-10',
+ 'iso-8859-10' => 'ISO-8859-10',
+ 'iso-ir-157' => 'ISO-8859-10',
+ 'iso8859-10' => 'ISO-8859-10',
+ 'iso885910' => 'ISO-8859-10',
+ 'l6' => 'ISO-8859-10',
+ 'latin6' => 'ISO-8859-10',
+ 'iso-8859-13' => 'ISO-8859-13',
+ 'iso8859-13' => 'ISO-8859-13',
+ 'iso885913' => 'ISO-8859-13',
+ 'iso-8859-14' => 'ISO-8859-14',
+ 'iso8859-14' => 'ISO-8859-14',
+ 'iso885914' => 'ISO-8859-14',
+ 'csisolatin9' => 'ISO-8859-15',
+ 'iso-8859-15' => 'ISO-8859-15',
+ 'iso8859-15' => 'ISO-8859-15',
+ 'iso885915' => 'ISO-8859-15',
+ 'iso_8859-15' => 'ISO-8859-15',
+ 'l9' => 'ISO-8859-15',
+ 'iso-8859-16' => 'ISO-8859-16',
+ 'cskoi8r' => 'KOI8-R',
+ 'koi' => 'KOI8-R',
+ 'koi8' => 'KOI8-R',
+ 'koi8-r' => 'KOI8-R',
+ 'koi8_r' => 'KOI8-R',
+ 'koi8-ru' => 'KOI8-U',
+ 'koi8-u' => 'KOI8-U',
+ 'csmacintosh' => 'macintosh',
+ 'mac' => 'macintosh',
+ 'macintosh' => 'macintosh',
+ 'x-mac-roman' => 'macintosh',
+ 'dos-874' => 'Windows-874',
+ 'iso-8859-11' => 'Windows-874',
+ 'iso8859-11' => 'Windows-874',
+ 'iso885911' => 'Windows-874',
+ 'tis-620' => 'Windows-874',
+ 'windows-874' => 'Windows-874',
+ 'cp1250' => 'Windows-1250',
+ 'windows-1250' => 'Windows-1250',
+ 'x-cp1250' => 'Windows-1250',
+ 'cp1251' => 'Windows-1251',
+ 'windows-1251' => 'Windows-1251',
+ 'x-cp1251' => 'Windows-1251',
+ 'ansi_x3.4-1968' => 'Windows-1252',
+ 'ascii' => 'Windows-1252',
+ 'cp1252' => 'Windows-1252',
+ 'cp819' => 'Windows-1252',
+ 'csisolatin1' => 'Windows-1252',
+ 'ibm819' => 'Windows-1252',
+ 'iso-8859-1' => 'Windows-1252',
+ 'iso-ir-100' => 'Windows-1252',
+ 'iso8859-1' => 'Windows-1252',
+ 'iso88591' => 'Windows-1252',
+ 'iso_8859-1' => 'Windows-1252',
+ 'iso_8859-1:1987' => 'Windows-1252',
+ 'l1' => 'Windows-1252',
+ 'latin1' => 'Windows-1252',
+ 'us-ascii' => 'Windows-1252',
+ 'windows-1252' => 'Windows-1252',
+ 'x-cp1252' => 'Windows-1252',
+ 'cp1253' => 'Windows-1253',
+ 'windows-1253' => 'Windows-1253',
+ 'x-cp1253' => 'Windows-1253',
+ 'cp1254' => 'Windows-1254',
+ 'csisolatin5' => 'Windows-1254',
+ 'iso-8859-9' => 'Windows-1254',
+ 'iso-ir-148' => 'Windows-1254',
+ 'iso8859-9' => 'Windows-1254',
+ 'iso88599' => 'Windows-1254',
+ 'iso_8859-9' => 'Windows-1254',
+ 'iso_8859-9:1989' => 'Windows-1254',
+ 'l5' => 'Windows-1254',
+ 'latin5' => 'Windows-1254',
+ 'windows-1254' => 'Windows-1254',
+ 'x-cp1254' => 'Windows-1254',
+ 'cp1255' => 'Windows-1255',
+ 'windows-1255' => 'Windows-1255',
+ 'x-cp1255' => 'Windows-1255',
+ 'cp1256' => 'Windows-1256',
+ 'windows-1256' => 'Windows-1256',
+ 'x-cp1256' => 'Windows-1256',
+ 'cp1257' => 'Windows-1257',
+ 'windows-1257' => 'Windows-1257',
+ 'x-cp1257' => 'Windows-1257',
+ 'cp1258' => 'Windows-1258',
+ 'windows-1258' => 'Windows-1258',
+ 'x-cp1258' => 'Windows-1258',
+ 'x-mac-cyrillic' => 'mac-cyrillic',
+ 'x-mac-ukrainian' => 'mac-cyrillic',
+ 'chinese' => 'GB18030', // GBK
+ 'csgb2312' => 'GB18030', // GBK
+ 'csiso58gb231280' => 'GB18030', // GBK
+ 'gb2312' => 'GB18030', // GBK
+ 'gb_2312' => 'GB18030', // GBK
+ 'gb_2312-80' => 'GB18030', // GBK
+ 'gbk' => 'GB18030', // GBK
+ 'iso-ir-58' => 'GB18030', // GBK
+ 'x-gbk' => 'GB18030', // GBK
+ 'gb18030' => 'GB18030',
+ 'big5' => 'BIG-5',
+ 'big5-hkscs' => 'BIG-5',
+ 'cn-big5' => 'BIG-5',
+ 'csbig5' => 'BIG-5',
+ 'x-x-big5' => 'BIG-5',
+ 'cseucpkdfmtjapanese' => 'EUC-JP',
+ 'euc-jp' => 'EUC-JP',
+ 'x-euc-jp' => 'EUC-JP',
+ 'csiso2022jp' => 'ISO-2022-JP',
+ 'iso-2022-jp' => 'ISO-2022-JP',
+ 'csshiftjis' => 'SJIS',
+ 'ms932' => 'SJIS',
+ 'ms_kanji' => 'SJIS',
+ 'shift-jis' => 'SJIS',
+ 'shift_jis' => 'SJIS',
+ 'sjis' => 'SJIS',
+ 'windows-31j' => 'SJIS',
+ 'x-sjis' => 'SJIS',
+ 'cseuckr' => 'EUC-KR',
+ 'csksc56011987' => 'EUC-KR',
+ 'euc-kr' => 'EUC-KR',
+ 'iso-ir-149' => 'EUC-KR',
+ 'korean' => 'EUC-KR',
+ 'ks_c_5601-1987' => 'EUC-KR',
+ 'ks_c_5601-1989' => 'EUC-KR',
+ 'ksc5601' => 'EUC-KR',
+ 'ksc_5601' => 'EUC-KR',
+ 'windows-949' => 'EUC-KR',
+ 'csiso2022kr' => 'replacement',
+ 'hz-gb-2312' => 'replacement',
+ 'iso-2022-cn' => 'replacement',
+ 'iso-2022-cn-ext' => 'replacement',
+ 'iso-2022-kr' => 'replacement',
+ 'utf-16be' => 'UTF-16BE',
+ 'utf-16' => 'UTF-16LE',
+ 'utf-16le' => 'UTF-16LE',
+ 'x-user-defined' => 'x-user-defined',
+ ];
+
+ /**
+ * Convert CSS text to UTF-8
+ * @param string $text Text being detected
+ * @param string[] $encodings Encodings to use at various points in the algorithm:
+ * - transport: Encoding from HTTP or the like
+ * - environment: Encoding from HTML `` or the like
+ * @return string
+ */
+ public static function convert( $text, $encodings = [] ) {
+ // First, check for a BOM and honor that if it's present.
+ if ( substr( $text, 0, 3 ) === "\xef\xbb\xbf" ) {
+ // UTF-8 with BOM (convert it anyway in case the BOM is a lie)
+ return self::doConvert( 'UTF-8', substr( $text, 3 ) );
+ }
+ $start = substr( $text, 0, 2 );
+ if ( $start === "\xfe\xff" ) {
+ return self::doConvert( 'UTF-16BE', substr( $text, 2 ) );
+ }
+ if ( $start === "\xff\xfe" ) {
+ return self::doConvert( 'UTF-16LE', substr( $text, 2 ) );
+ }
+
+ // 1. Transport encoding
+ $encoding = isset( $encodings['transport'] )
+ ? trim( strtolower( $encodings['transport'] ), "\t\n\f\r " )
+ : null;
+ if ( $encoding !== null && isset( self::$encodings[$encoding] ) ) {
+ return self::doConvert( self::$encodings[$encoding], $text );
+ }
+
+ // 2. @charset rule
+ if ( preg_match( '/^@charset "([\x00-\x21\x23-\x7f]{0,1012})";/', $text, $m ) ) {
+ $encoding = trim( strtolower( $m[1] ), "\t\n\f\r " );
+ if ( $encoding === 'utf-16be' || $encoding === 'utf-16le' ) {
+ // It's obviously lying.
+ $encoding = 'utf-8';
+ }
+ if ( isset( self::$encodings[$encoding] ) ) {
+ return self::doConvert( self::$encodings[$encoding], $text );
+ }
+ }
+
+ // 3. Environment encoding
+ $encoding = isset( $encodings['environment'] )
+ ? trim( strtolower( $encodings['environment'] ), "\t\n\f\r " )
+ : null;
+ if ( $encoding !== null && isset( self::$encodings[$encoding] ) ) {
+ return self::doConvert( self::$encodings[$encoding], $text );
+ }
+
+ // 4. Just use UTF-8
+ return self::doConvert( 'UTF-8', $text );
+ }
+
+ /**
+ * Actually perform the conversion
+ * @param string $encoding
+ * @param string $text
+ * @return string
+ */
+ protected static function doConvert( $encoding, $text ) {
+ // Pseudo-encoding that just outputs one replacement character
+ if ( $encoding === 'replacement' ) {
+ return \UtfNormal\Constants::UTF8_REPLACEMENT;
+ }
+
+ // Pseudo-encoding that shifts non-ASCII bytes to the BMP private use area
+ if ( $encoding === 'x-user-defined' ) {
+ return preg_replace_callback( '/[\x80-\xff]/', function ( $m ) {
+ return \UtfNormal\Utils::codepointToUtf8( 0xf700 + ord( $m[0] ) );
+ }, $text );
+ }
+
+ // We prefer mbstring because it has sane handling of invalid input,
+ // where iconv just chokes and returns false. But we need iconv for
+ // some encodings mbstring doesn't support.
+ if ( in_array( $encoding, mb_list_encodings(), true ) ) {
+ $old = mb_substitute_character();
+ mb_substitute_character( \UtfNormal\Constants::UNICODE_REPLACEMENT );
+ $text = mb_convert_encoding( $text, 'UTF-8', $encoding );
+ mb_substitute_character( $old );
+ return $text;
+ }
+
+ $ret = \MediaWiki\quietCall( 'iconv', $encoding, 'UTF-8', $text );
+ if ( $ret === false ) {
+ throw new \RuntimeException( "Cannot convert '$text' from $encoding" );
+ }
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/Parser.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/Parser.php
new file mode 100644
index 000000000..d1f152f9f
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/Parser.php
@@ -0,0 +1,658 @@
+ tag.
+ * - Parser::parseDeclarationList() to parse an inline style attribute
+ */
+class Parser {
+ /** Maximum depth of nested ComponentValues */
+ const CV_DEPTH_LIMIT = 100; // Arbitrary number that seems like it should be enough
+
+ /** @var Tokenizer */
+ protected $tokenizer;
+
+ /** @var Token|null The most recently consumed token */
+ protected $currentToken = null;
+
+ /** @var array Parse errors. Each error is [ string $tag, int $line, int $pos ] */
+ protected $parseErrors = [];
+
+ /** @var int Recursion depth, incremented in self::consumeComponentValue() */
+ protected $cvDepth = 0;
+
+ /**
+ * @param Tokenizer $tokenizer CSS Tokenizer
+ */
+ public function __construct( Tokenizer $tokenizer ) {
+ $this->tokenizer = $tokenizer;
+ }
+
+ /**
+ * Create a Parser for a CSS string
+ * @param string $source CSS to parse.
+ * @param array $options Configuration options, see DataSourceTokenizer::__construct(). Also,
+ * - convert: (array) If specified, detect the encoding as defined in the
+ * CSS spec. The value is passed as the $encodings argument to
+ * Encoder::convert().
+ * @return static
+ */
+ public static function newFromString( $source, array $options = [] ) {
+ if ( isset( $options['convert'] ) ) {
+ $source = Encoder::convert( $source, $options['convert'] );
+ }
+ return static::newFromDataSource( new StringDataSource( $source ), $options );
+ }
+
+ /**
+ * Create a Parser for a CSS DataSource
+ * @param DataSource $source CSS to parse.
+ * @param array $options Configuration options, see DataSourceTokenizer::__construct().
+ * @return static
+ */
+ public static function newFromDataSource( DataSource $source, array $options = [] ) {
+ $tokenizer = new DataSourceTokenizer( $source, $options );
+ return new static( $tokenizer );
+ }
+
+ /**
+ * Create a Parser for a list of Tokens
+ * @param Token[] $tokens Token-stream to parse
+ * @param Token|null $eof EOF-token
+ * @return static
+ */
+ public static function newFromTokens( array $tokens, Token $eof = null ) {
+ $tokenizer = new TokenListTokenizer( $tokens, $eof );
+ return new static( $tokenizer );
+ }
+
+ /**
+ * Consume a token
+ */
+ protected function consumeToken() {
+ if ( !$this->currentToken || $this->currentToken->type() !== Token::T_EOF ) {
+ $this->currentToken = $this->tokenizer->consumeToken();
+
+ // Copy any parse errors encountered
+ foreach ( $this->tokenizer->getParseErrors() as $error ) {
+ $this->parseErrors[] = $error;
+ }
+ $this->tokenizer->clearParseErrors();
+ }
+ }
+
+ /**
+ * Consume a token, also consuming any following whitespace (and comments)
+ */
+ protected function consumeTokenAndWhitespace() {
+ do {
+ $this->consumeToken();
+ } while ( $this->currentToken->type() === Token::T_WHITESPACE );
+ }
+
+ /**
+ * Return all parse errors seen so far
+ * @return array Array of [ string $tag, int $line, int $pos, ... ]
+ */
+ public function getParseErrors() {
+ return $this->parseErrors;
+ }
+
+ /**
+ * Clear parse errors
+ */
+ public function clearParseErrors() {
+ $this->parseErrors = [];
+ }
+
+ /**
+ * Record a parse error
+ * @param string $tag Error tag
+ * @param Token $token Report the error as starting at this token.
+ * @param array $data Extra data about the error.
+ */
+ protected function parseError( $tag, Token $token, array $data = [] ) {
+ list( $line, $pos ) = $token->getPosition();
+ $this->parseErrors[] = array_merge( [ $tag, $line, $pos ], $data );
+ }
+
+ /**
+ * Parse a stylesheet
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-stylesheet
+ * @note Per the Editor's Draft, if the first rule is an at-rule named
+ * "charset" it will be silently dropped. If you're not using the provided
+ * Sanitizer classes to further sanitize the CSS, you'll want to manually
+ * filter out any other such rules before stringifying the stylesheet
+ * and/or prepend `@charset "utf-8";` after stringifying it.
+ * @return Stylesheet
+ */
+ public function parseStylesheet() {
+ $this->consumeToken(); // Move to the first token
+ $list = $this->consumeRuleList( true );
+
+ // Drop @charset per the Editor's Draft
+ if ( isset( $list[0] ) && $list[0] instanceof AtRule &&
+ !strcasecmp( $list[0]->getName(), 'charset' )
+ ) {
+ $list->remove( 0 );
+ $list->rewind();
+ }
+
+ return new Stylesheet( $list );
+ }
+
+ /**
+ * Parse a list of rules
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-list-of-rules
+ * @return RuleList
+ */
+ public function parseRuleList() {
+ $this->consumeToken(); // Move to the first token
+ return $this->consumeRuleList( false );
+ }
+
+ /**
+ * Parse a rule
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-rule
+ * @return Rule|null
+ */
+ public function parseRule() {
+ // 1. and 2.
+ $this->consumeTokenAndWhitespace();
+
+ // 3.
+ if ( $this->currentToken->type() === Token::T_EOF ) {
+ $this->parseError( 'unexpected-eof', $this->currentToken ); // "return a syntax error"?
+ return null;
+ }
+
+ if ( $this->currentToken->type() === Token::T_AT_KEYWORD ) {
+ $rule = $this->consumeAtRule();
+ } else {
+ $rule = $this->consumeQualifiedRule();
+ if ( !$rule ) {
+ return null;
+ }
+ }
+
+ // 4.
+ $this->consumeTokenAndWhitespace();
+
+ // 5.
+ if ( $this->currentToken->type() === Token::T_EOF ) {
+ return $rule;
+ } else {
+ $this->parseError( 'expected-eof', $this->currentToken ); // "return a syntax error"?
+ return null;
+ }
+ }
+
+ /**
+ * Parse a declaration
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-declaration
+ * @return Declaration|null
+ */
+ public function parseDeclaration() {
+ // 1. and 2.
+ $this->consumeTokenAndWhitespace();
+
+ // 3.
+ if ( $this->currentToken->type() !== Token::T_IDENT ) {
+ $this->parseError( 'expected-ident', $this->currentToken ); // "return a syntax error"?
+ return null;
+ }
+
+ // 4.
+ $declaration = $this->consumeDeclaration();
+
+ // Declarations always run to EOF, no need to check.
+
+ return $declaration;
+ }
+
+ /**
+ * Parse a list of declarations
+ * @note This is not the entry point the standard calls "parse a list of declarations",
+ * see self::parseDeclarationOrAtRuleList()
+ * @return DeclarationList
+ */
+ public function parseDeclarationList() {
+ $this->consumeToken(); // Move to the first token
+ return $this->consumeDeclarationOrAtRuleList( false );
+ }
+
+ /**
+ * Parse a list of declarations and at-rules
+ * @note This is the entry point the standard calls "parse a list of declarations"
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-list-of-declarations
+ * @return DeclarationOrAtRuleList
+ */
+ public function parseDeclarationOrAtRuleList() {
+ $this->consumeToken(); // Move to the first token
+ return $this->consumeDeclarationOrAtRuleList();
+ }
+
+ /**
+ * Parse a (non-whitespace) component value
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-component-value
+ * @return ComponentValue|null
+ */
+ public function parseComponentValue() {
+ // 1. and 2.
+ $this->consumeTokenAndWhitespace();
+
+ // 3.
+ if ( $this->currentToken->type() === Token::T_EOF ) {
+ $this->parseError( 'unexpected-eof', $this->currentToken ); // "return a syntax error"?
+ return null;
+ }
+
+ // 4.
+ $value = $this->consumeComponentValue();
+ // The spec says to return a syntax error if nothing is returned, but
+ // that can never happen and the Editor's Draft removed that language.
+
+ // 5.
+ $this->consumeTokenAndWhitespace();
+
+ // 6.
+ if ( $this->currentToken->type() === Token::T_EOF ) {
+ return $value;
+ } else {
+ $this->parseError( 'expected-eof', $this->currentToken ); // "return a syntax error"?
+ return null;
+ }
+
+ }
+
+ /**
+ * Parse a list of component values
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#parse-a-list-of-component-values
+ * @return ComponentValueList
+ */
+ public function parseComponentValueList() {
+ $list = new ComponentValueList();
+ while ( true ) {
+ $this->consumeToken(); // Move to the first/next token
+ $value = $this->consumeComponentValue();
+ if ( $value instanceof Token && $value->type() === Token::T_EOF ) {
+ break;
+ }
+ $list->add( $value );
+ }
+
+ return $list;
+ }
+
+ /**
+ * Consume a list of rules
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-list-of-rules
+ * @param boolean $topLevel Determines the behavior when CDO and CDC tokens are encountered
+ * @return RuleList
+ */
+ protected function consumeRuleList( $topLevel ) {
+ $list = new RuleList();
+ while ( true ) {
+ $rule = false;
+ switch ( $this->currentToken->type() ) {
+ case Token::T_WHITESPACE:
+ break;
+
+ case Token::T_EOF:
+ break 2;
+
+ case Token::T_CDO:
+ case Token::T_CDC:
+ if ( $topLevel ) {
+ // Do nothing
+ } else {
+ $rule = $this->consumeQualifiedRule();
+ }
+ break;
+
+ case Token::T_AT_KEYWORD:
+ $rule = $this->consumeAtRule();
+ break;
+
+ default:
+ $rule = $this->consumeQualifiedRule();
+ break;
+ }
+
+ if ( $rule ) {
+ $list->add( $rule );
+ }
+ $this->consumeToken();
+ }
+
+ return $list;
+ }
+
+ /**
+ * Consume a list of declarations and at-rules
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-list-of-declarations
+ * @param bool $allowAtRules Whether to allow at-rules. This flag is not in
+ * the spec, and is used to implement the non-spec self::parseDeclarationList().
+ * @return DeclarationOrAtRuleList|DeclarationList
+ */
+ protected function consumeDeclarationOrAtRuleList( $allowAtRules = true ) {
+ $list = $allowAtRules ? new DeclarationOrAtRuleList() : new DeclarationList();
+ while ( true ) {
+ $declaration = false;
+ switch ( $this->currentToken->type() ) {
+ case Token::T_WHITESPACE:
+ break;
+
+ case Token::T_SEMICOLON:
+ $declaration = null;
+ break;
+
+ case Token::T_EOF:
+ break 2;
+
+ case Token::T_AT_KEYWORD:
+ if ( $allowAtRules ) {
+ $declaration = $this->consumeAtRule();
+ } else {
+ $this->parseError( 'unexpected-token-in-declaration-list', $this->currentToken );
+ $this->consumeAtRule();
+ $declaration = null;
+ }
+ break;
+
+ case Token::T_IDENT:
+ // The draft changes this to ComponentValue instead of Token, which makes more sense.
+ $cvs = [];
+ do {
+ $cvs[] = $this->consumeComponentValue();
+ $this->consumeToken();
+ } while (
+ $this->currentToken->type() !== Token::T_SEMICOLON &&
+ $this->currentToken->type() !== Token::T_EOF
+ );
+ $tokens = ( new ComponentValueList( $cvs ) )->toTokenArray();
+ $parser = static::newFromTokens( $tokens, $this->currentToken );
+ $parser->consumeToken(); // Load that first token
+ $declaration = $parser->consumeDeclaration();
+ // Propagate any errors
+ $this->parseErrors = array_merge( $this->parseErrors, $parser->parseErrors );
+ break;
+
+ default:
+ $this->parseError( 'unexpected-token-in-declaration-list', $this->currentToken );
+ do {
+ $this->consumeComponentValue();
+ $this->consumeToken();
+ } while (
+ $this->currentToken->type() !== Token::T_SEMICOLON &&
+ $this->currentToken->type() !== Token::T_EOF
+ );
+ $declaration = null;
+ break;
+ }
+
+ if ( $declaration ) {
+ $list->add( $declaration );
+ }
+ $this->consumeToken();
+ }
+
+ return $list;
+ }
+
+ /**
+ * Consume a declaration
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-declaration
+ * @return Declaration|null
+ */
+ protected function consumeDeclaration() {
+ $declaration = new Declaration( $this->currentToken );
+
+ // 2.
+ $this->consumeTokenAndWhitespace();
+
+ // 3.
+ if ( $this->currentToken->type() !== Token::T_COLON ) {
+ $this->parseError( 'expected-colon', $this->currentToken );
+ return null;
+ }
+ $this->consumeToken();
+
+ // 4.
+ $value = $declaration->getValue();
+ $l1 = $l2 = -1;
+ while ( $this->currentToken->type() !== Token::T_EOF ) {
+ // The draft changes this to ComponentValue instead of Token, which makes more sense.
+ $value->add( $this->consumeComponentValue() );
+ if ( $this->currentToken->type() !== Token::T_WHITESPACE ) {
+ $l1 = $l2;
+ $l2 = $value->count() - 1;
+ }
+ $this->consumeToken();
+ }
+
+ // 5.
+ $v1 = $l1 >= 0 ? $value[$l1] : null;
+ $v2 = $l2 >= 0 ? $value[$l2] : null;
+ if ( $v1 instanceof Token && $v1->type() === Token::T_DELIM && $v1->value() === '!' &&
+ $v2 instanceof Token && $v2->type() === Token::T_IDENT &&
+ !strcasecmp( $v2->value(), 'important' )
+ ) {
+ // Technically it doesn't say to remove any whitespace within/after
+ // the "!important" too, but it makes sense to do so.
+ while ( isset( $value[$l1] ) ) {
+ $value->remove( $l1 );
+ }
+ $declaration->setImportant( true );
+ }
+
+ // 6.
+ return $declaration;
+ }
+
+ /**
+ * Consume an at-rule
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-an-at-rule
+ * @return AtRule
+ */
+ protected function consumeAtRule() {
+ $rule = new AtRule( $this->currentToken );
+ $this->consumeToken();
+ while ( true ) {
+ switch ( $this->currentToken->type() ) {
+ case Token::T_SEMICOLON:
+ return $rule;
+
+ case Token::T_EOF:
+ // Parse error from the editor's draft as of 2017-01-11
+ if ( $this->currentToken->typeFlag() !== 'recursion-depth-exceeded' ) {
+ $this->parseError( 'unexpected-eof-in-rule', $this->currentToken );
+ }
+ return $rule;
+
+ case Token::T_LEFT_BRACE:
+ $rule->setBlock( $this->consumeSimpleBlock( true ) );
+ return $rule;
+
+ default:
+ $rule->getPrelude()->add( $this->consumeComponentValue() );
+ break;
+ }
+ $this->consumeToken();
+ }
+ // @codeCoverageIgnoreStart
+ }
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * Consume a qualified rule
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-qualified-rule
+ * @return QualifiedRule|null
+ */
+ protected function consumeQualifiedRule() {
+ $rule = new QualifiedRule( $this->currentToken );
+ while ( true ) {
+ switch ( $this->currentToken->type() ) {
+ case Token::T_EOF:
+ if ( $this->currentToken->typeFlag() !== 'recursion-depth-exceeded' ) {
+ $this->parseError( 'unexpected-eof-in-rule', $this->currentToken );
+ }
+ return null;
+
+ case Token::T_LEFT_BRACE:
+ $rule->setBlock( $this->consumeSimpleBlock( true ) );
+ return $rule;
+
+ default:
+ $rule->getPrelude()->add( $this->consumeComponentValue() );
+ break;
+ }
+ $this->consumeToken();
+ }
+ // @codeCoverageIgnoreStart
+ }
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * Consume a component value
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-component-value
+ * @return ComponentValue
+ */
+ protected function consumeComponentValue() {
+ if ( ++$this->cvDepth > static::CV_DEPTH_LIMIT ) {
+ $this->parseError( 'recursion-depth-exceeded', $this->currentToken );
+ // There's no way to safely recover from this without more recursion.
+ // So just eat the rest of the input, then return a
+ // specially-flagged EOF so we can avoid 100 "unexpected EOF"
+ // errors.
+ $position = $this->currentToken->getPosition();
+ while ( $this->currentToken->type() !== Token::T_EOF ) {
+ $this->consumeToken();
+ }
+ $this->currentToken = new Token( Token::T_EOF, [
+ 'position' => $position,
+ 'typeFlag' => 'recursion-depth-exceeded'
+ ] );
+ }
+
+ switch ( $this->currentToken->type() ) {
+ case Token::T_LEFT_BRACE:
+ case Token::T_LEFT_BRACKET:
+ case Token::T_LEFT_PAREN:
+ $ret = $this->consumeSimpleBlock();
+ break;
+
+ case Token::T_FUNCTION:
+ $ret = $this->consumeFunction();
+ break;
+
+ default:
+ $ret = $this->currentToken;
+ break;
+ }
+
+ $this->cvDepth--;
+ return $ret;
+ }
+
+ /**
+ * Consume a simple block
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-simple-block
+ * @return SimpleBlock
+ */
+ protected function consumeSimpleBlock() {
+ $block = new SimpleBlock( $this->currentToken );
+ $endTokenType = $block->getEndTokenType();
+ $this->consumeToken();
+ while ( true ) {
+ switch ( $this->currentToken->type() ) {
+ case Token::T_EOF:
+ // Parse error from the editor's draft as of 2017-01-12
+ if ( $this->currentToken->typeFlag() !== 'recursion-depth-exceeded' ) {
+ $this->parseError( 'unexpected-eof-in-block', $this->currentToken );
+ }
+ return $block;
+
+ case $endTokenType:
+ return $block;
+
+ default:
+ $block->getValue()->add( $this->consumeComponentValue() );
+ break;
+ }
+ $this->consumeToken();
+ }
+ // @codeCoverageIgnoreStart
+ }
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * Consume a function
+ * @see https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#consume-a-function
+ * @return CSSFunction
+ */
+ protected function consumeFunction() {
+ $function = new CSSFunction( $this->currentToken );
+ $this->consumeToken();
+
+ while ( true ) {
+ switch ( $this->currentToken->type() ) {
+ case Token::T_EOF:
+ // Parse error from the editor's draft as of 2017-01-12
+ if ( $this->currentToken->typeFlag() !== 'recursion-depth-exceeded' ) {
+ $this->parseError( 'unexpected-eof-in-function', $this->currentToken );
+ }
+ return $function;
+
+ case Token::T_RIGHT_PAREN:
+ return $function;
+
+ default:
+ $function->getValue()->add( $this->consumeComponentValue() );
+ break;
+ }
+ $this->consumeToken();
+ }
+ // @codeCoverageIgnoreStart
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/StringDataSource.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/StringDataSource.php
new file mode 100644
index 000000000..378c0cf7d
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/StringDataSource.php
@@ -0,0 +1,91 @@
+string = (string)$string;
+ $this->len = strlen( $this->string );
+
+ // HHVM 3.4 and older come with an outdated version of libmbfl that
+ // incorrectly allows values above U+10FFFF, so we have to check
+ // for them separately. (This issue also exists in PHP 5.3 and
+ // older, which are no longer supported.)
+ // @codeCoverageIgnoreStart
+ if ( $newPHP === null ) {
+ $newPHP = !mb_check_encoding( "\xf4\x90\x80\x80", 'UTF-8' );
+ }
+ // @codeCoverageIgnoreEnd
+
+ if ( !mb_check_encoding( $this->string, 'UTF-8' ) ||
+ !$newPHP && preg_match( "/\xf4[\x90-\xbf]|[\xf5-\xff]/S", $this->string ) !== 0
+ ) {
+ throw new \InvalidArgumentException( '$string is not valid UTF-8' );
+ }
+ }
+
+ public function readCharacter() {
+ if ( $this->putBack ) {
+ return array_pop( $this->putBack );
+ }
+
+ if ( $this->pos >= $this->len ) {
+ return self::EOF;
+ }
+
+ // We already checked that the string is valid UTF-8 in the
+ // constructor, so we can do a quick binary "get next character" here.
+ $p = $this->pos;
+ $c = $this->string[$p];
+ $cc = ord( $this->string[$p] );
+ if ( $cc <= 0x7f ) {
+ $this->pos += 1;
+ return $c;
+ } elseif ( ( $cc & 0xe0 ) === 0xc0 ) {
+ $this->pos += 2;
+ return substr( $this->string, $p, 2 );
+ } elseif ( ( $cc & 0xf0 ) === 0xe0 ) {
+ $this->pos += 3;
+ return substr( $this->string, $p, 3 );
+ } elseif ( ( $cc & 0xf8 ) === 0xf0 ) {
+ $this->pos += 4;
+ return substr( $this->string, $p, 4 );
+ } else {
+ // WTF? Should never get here because it should have failed
+ // validation in the constructor.
+ // @codeCoverageIgnoreStart
+ throw new \UnexpectedValueException(
+ sprintf( 'Unexpected byte %02X in string at position %d.', $cc, $this->pos )
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ public function putBackCharacter( $char ) {
+ if ( $char !== self::EOF ) {
+ $this->putBack[] = $char;
+ }
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/TokenListTokenizer.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/TokenListTokenizer.php
new file mode 100644
index 000000000..4010d4d0c
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/TokenListTokenizer.php
@@ -0,0 +1,60 @@
+tokens = $tokens->toTokenArray();
+ } elseif ( is_array( $tokens ) ) {
+ Util::assertAllInstanceOf( $tokens, Token::class, '$tokens' );
+ $this->tokens = $tokens;
+ } else {
+ throw new \InvalidArgumentException( '$tokens must be a TokenList or an array of tokens' );
+ }
+
+ if ( $eof && $eof->type() === Token::T_EOF ) {
+ $this->eof = $eof;
+ } else {
+ $data = [];
+ if ( $eof ) {
+ $data['position'] = $eof->getPosition();
+ }
+ $this->eof = new Token( Token::T_EOF, $data );
+ }
+ }
+
+ public function getParseErrors() {
+ return [];
+ }
+
+ public function clearParseErrors() {
+ }
+
+ public function consumeToken() {
+ return array_shift( $this->tokens ) ?: $this->eof;
+ }
+
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Parser/Tokenizer.php b/lib/css-sanitizer/Wikimedia/CSS/Parser/Tokenizer.php
new file mode 100644
index 000000000..87a5b0fb2
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Parser/Tokenizer.php
@@ -0,0 +1,33 @@
+propertySanitizer = new PropertySanitizer();
+ $this->propertySanitizer->setKnownProperties( [
+ 'font-family' => $matchData['familyName'],
+ 'src' => Quantifier::hash( new Alternative( [
+ new Juxtaposition( [
+ $matcherFactory->url( 'font' ),
+ Quantifier::optional(
+ new FunctionMatcher( 'format', Quantifier::hash( $matcherFactory->string() ) )
+ ),
+ ] ),
+ new FunctionMatcher( 'local', $matchData['familyName'] ),
+ ] ) ),
+ 'font-style' => $matchData['font-style'],
+ 'font-weight' => new Alternative( [
+ new KeywordMatcher( [ 'normal', 'bold' ] ), $matchData['numWeight']
+ ] ),
+ 'font-stretch' => $matchData['font-stretch'],
+ 'unicode-range' => Quantifier::hash(
+ new TokenMatcher( Token::T_UNICODE_RANGE, function ( Token $t ) {
+ list( $start, $end ) = $t->range();
+ return $start <= $end && $end <= 0x10ffff;
+ } )
+ ),
+ 'font-variant' => $matchData['font-variant'],
+ 'font-feature-settings' => $matchData['font-feature-settings'],
+ ] );
+ }
+
+ /**
+ * Get some shared data for font declaration matchers
+ * @param MatcherFactory $matcherFactory
+ * @return array
+ */
+ public static function fontMatchData( MatcherFactory $matcherFactory ) {
+ $featureValueName = $matcherFactory->ident();
+ $featureValueNameHash = Quantifier::hash( $featureValueName );
+ $ret = [
+ 'familyName' => new Alternative( [
+ $matcherFactory->string(),
+ Quantifier::plus( $matcherFactory->ident() ),
+ ] ),
+ 'numWeight' => new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^[1-9]00$/', $t->representation() );
+ } ),
+ 'font-style' => new KeywordMatcher( [ 'normal', 'italic', 'oblique' ] ),
+ 'font-stretch' => new KeywordMatcher( [
+ 'normal', 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', 'semi-expanded',
+ 'expanded', 'extra-expanded', 'ultra-expanded'
+ ] ),
+ 'font-feature-settings' => new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ Quantifier::hash( new Juxtaposition( [
+ new TokenMatcher( Token::T_STRING, function ( Token $t ) {
+ return preg_match( '/^[\x20-\x7e]{4}$/', $t->value() );
+ } ),
+ Quantifier::optional( new Alternative( [
+ $matcherFactory->integer(),
+ new KeywordMatcher( [ 'on', 'off' ] ),
+ ] ) )
+ ] ) )
+ ] ),
+ 'ligatures' => [
+ new KeywordMatcher( [ 'common-ligatures', 'no-common-ligatures' ] ),
+ new KeywordMatcher( [ 'discretionary-ligatures', 'no-discretionary-ligatures' ] ),
+ new KeywordMatcher( [ 'historical-ligatures', 'no-historical-ligatures' ] ),
+ new KeywordMatcher( [ 'contextual', 'no-contextual' ] )
+ ],
+ 'alt' => [
+ new FunctionMatcher( 'stylistic', $featureValueName ),
+ new KeywordMatcher( 'historical-forms' ),
+ new FunctionMatcher( 'styleset', $featureValueNameHash ),
+ new FunctionMatcher( 'character-variant', $featureValueNameHash ),
+ new FunctionMatcher( 'swash', $featureValueName ),
+ new FunctionMatcher( 'ornaments', $featureValueName ),
+ new FunctionMatcher( 'annotation', $featureValueName ),
+ ],
+ 'capsKeywords' => [
+ 'small-caps', 'all-small-caps', 'petite-caps', 'all-petite-caps', 'unicase', 'titling-caps'
+ ],
+ 'numeric' => [
+ new KeywordMatcher( [ 'lining-nums', 'oldstyle-nums' ] ),
+ new KeywordMatcher( [ 'proportional-nums', 'tabular-nums' ] ),
+ new KeywordMatcher( [ 'diagonal-fractions', 'stacked-fractions' ] ),
+ new KeywordMatcher( 'ordinal' ),
+ new KeywordMatcher( 'slashed-zero' ),
+ ],
+ 'eastAsian' => [
+ new KeywordMatcher( [ 'jis78', 'jis83', 'jis90', 'jis04', 'simplified', 'traditional' ] ),
+ new KeywordMatcher( [ 'full-width', 'proportional-width' ] ),
+ new KeywordMatcher( 'ruby' ),
+ ]
+ ];
+ $ret['font-variant'] = new Alternative( [
+ new KeywordMatcher( [ 'normal', 'none' ] ),
+ UnorderedGroup::someOf( array_merge(
+ $ret['ligatures'],
+ $ret['alt'],
+ [ new KeywordMatcher( $ret['capsKeywords'] ) ],
+ $ret['numeric'],
+ $ret['eastAsian']
+ ) )
+ ] );
+ return $ret;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'font-face' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'font-face' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'font-face' ] );
+ return null;
+ }
+
+ // No non-whitespace prelude allowed
+ if ( Util::findFirstNonWhitespace( $object->getPrelude() ) ) {
+ $this->sanitizationError( 'invalid-font-face-at-rule', $object );
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValueAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValueAtRuleSanitizer.php
new file mode 100644
index 000000000..01e6731b9
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValueAtRuleSanitizer.php
@@ -0,0 +1,83 @@
+name = $name;
+ $this->valueMatcher = $valueMatcher;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), $this->name );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ $this->name ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ $this->name ] );
+ return null;
+ }
+
+ // No non-whitespace prelude allowed
+ if ( Util::findFirstNonWhitespace( $object->getPrelude() ) ) {
+ $this->sanitizationError( 'invalid-font-feature-value', $object, [ $this->name ] );
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+
+ // Parse the block's contents into a list of declarations, sanitize it,
+ // and put it back into the block.
+ $blockContents = $ret->getBlock()->getValue();
+ $parser = Parser::newFromTokens( $blockContents->toTokenArray() );
+ $oldDeclarations = $parser->parseDeclarationList();
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $parser->getParseErrors() );
+ $newDeclarations = new DeclarationList();
+ foreach ( $oldDeclarations as $declaration ) {
+ if ( $this->valueMatcher->match( $declaration->getValue(), [ 'mark-significance' => true ] ) ) {
+ $newDeclarations->add( $declaration );
+ } else {
+ $this->sanitizationError( 'invalid-font-feature-value-declaration', $declaration,
+ [ $this->name ] );
+ }
+ }
+ $blockContents->clear();
+ $blockContents->add( $newDeclarations->toComponentValueArray() );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValuesAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValuesAtRuleSanitizer.php
new file mode 100644
index 000000000..ac132673e
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValuesAtRuleSanitizer.php
@@ -0,0 +1,84 @@
+fontListMatcher = Quantifier::hash( new Alternative( [
+ $matcherFactory->string(),
+ Quantifier::plus( $matcherFactory->ident() ),
+ ] ) );
+
+ $n = $matcherFactory->rawNumber();
+ $n2 = Quantifier::count( $n, 1, 2 );
+ $nPlus = Quantifier::plus( $n );
+ $this->ruleSanitizers = [
+ new FontFeatureValueAtRuleSanitizer( 'stylistic', $n ),
+ new FontFeatureValueAtRuleSanitizer( 'styleset', $nPlus ),
+ new FontFeatureValueAtRuleSanitizer( 'character-variant', $n2 ),
+ new FontFeatureValueAtRuleSanitizer( 'swash', $n ),
+ new FontFeatureValueAtRuleSanitizer( 'ornaments', $n ),
+ new FontFeatureValueAtRuleSanitizer( 'annotation', $n ),
+ ];
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'font-feature-values' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'font-feature-values' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'font-feature-values' ] );
+ return null;
+ }
+
+ // Test the page selector
+ if ( !$this->fontListMatcher->match( $object->getPrelude(), [ 'mark-significance' => true ] ) ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-font-feature-values-font-list', $cv );
+ } else {
+ $this->sanitizationError( 'missing-font-feature-values-font-list', $object );
+ }
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeRuleBlock( $ret->getBlock(), $this->ruleSanitizers );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/ImportAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/ImportAtRuleSanitizer.php
new file mode 100644
index 000000000..d1f8b8914
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/ImportAtRuleSanitizer.php
@@ -0,0 +1,72 @@
+matcher = new Juxtaposition( [
+ new Alternative( [
+ $matcherFactory->url( 'css' ),
+ $matcherFactory->urlstring( 'css' ),
+ ] ),
+ $matcherFactory->cssMediaQueryList(),
+ ] );
+ }
+
+ public function getIndex() {
+ return -1000;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'import' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'import' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() !== null ) {
+ $this->sanitizationError( 'at-rule-block-not-allowed', $object->getBlock(), [ 'import' ] );
+ return null;
+ }
+ if ( !$this->matcher->match( $object->getPrelude(), [ 'mark-significance' => true ] ) ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-import-value', $cv );
+ } else {
+ $this->sanitizationError( 'missing-import-source', $object );
+ }
+ return null;
+ }
+ $object = $this->fixPreludeWhitespace( $object, true );
+
+ return $object;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/KeyframesAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/KeyframesAtRuleSanitizer.php
new file mode 100644
index 000000000..b27f7f8cd
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/KeyframesAtRuleSanitizer.php
@@ -0,0 +1,79 @@
+identMatcher = $matcherFactory->ident();
+ $this->ruleSanitizer = new StyleRuleSanitizer(
+ Quantifier::hash( new Alternative( [
+ new KeywordMatcher( [ 'from', 'to' ] ), $matcherFactory->rawPercentage()
+ ] ) ),
+ $propertySanitizer
+ );
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'keyframes' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'keyframes' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'keyframes' ] );
+ return null;
+ }
+
+ // Test the keyframe name
+ if ( !$this->identMatcher->match( $object->getPrelude(), [ 'mark-significance' => true ] ) ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-keyframe-name', $cv );
+ } else {
+ $this->sanitizationError( 'missing-keyframe-name', $object );
+ }
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeRuleBlock( $ret->getBlock(), [ $this->ruleSanitizer ] );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MarginAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MarginAtRuleSanitizer.php
new file mode 100644
index 000000000..02725e226
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MarginAtRuleSanitizer.php
@@ -0,0 +1,65 @@
+propertySanitizer = $propertySanitizer;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule &&
+ in_array( strtolower( $rule->getName() ), self::$marginRuleNames, true );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-page-margin-at-rule', $object );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ $object->getName() ] );
+ return null;
+ }
+
+ // No non-whitespace prelude allowed
+ if ( Util::findFirstNonWhitespace( $object->getPrelude() ) ) {
+ $this->sanitizationError( 'invalid-page-margin-at-rule', $object, [ $object->getName() ] );
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MediaAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MediaAtRuleSanitizer.php
new file mode 100644
index 000000000..ba9fb11dd
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MediaAtRuleSanitizer.php
@@ -0,0 +1,84 @@
+mediaQueryListMatcher = $mediaQueryListMatcher;
+ }
+
+ /**
+ * Access the list of rule sanitizers
+ * @return RuleSanitizer[]
+ */
+ public function getRuleSanitizers() {
+ return $this->ruleSanitizers;
+ }
+
+ /**
+ * Set the list of rule sanitizers
+ * @param RuleSanitizer[] $ruleSanitizers
+ */
+ public function setRuleSanitizers( array $ruleSanitizers ) {
+ Util::assertAllInstanceOf( $ruleSanitizers, RuleSanitizer::class, '$ruleSanitizers' );
+ $this->ruleSanitizers = $ruleSanitizers;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'media' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'media' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'media' ] );
+ return null;
+ }
+
+ // Test the media query
+ $match = $this->mediaQueryListMatcher->match(
+ $object->getPrelude(), [ 'mark-significance' => true ]
+ );
+ if ( !$match ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() ) ?: $object->getPrelude();
+ $this->sanitizationError( 'invalid-media-query', $cv );
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeRuleBlock( $ret->getBlock(), $this->ruleSanitizers );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/NamespaceAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/NamespaceAtRuleSanitizer.php
new file mode 100644
index 000000000..257a675e8
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/NamespaceAtRuleSanitizer.php
@@ -0,0 +1,72 @@
+matcher = new Juxtaposition( [
+ Quantifier::optional( $matcherFactory->ident() ),
+ new Alternative( [
+ $matcherFactory->urlstring( 'namespace' ),
+ $matcherFactory->url( 'namespace' ),
+ ] ),
+ ] );
+ }
+
+ public function getIndex() {
+ return -900;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'namespace' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'namespace' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() !== null ) {
+ $this->sanitizationError( 'at-rule-block-not-allowed', $object->getBlock(), [ 'namespace' ] );
+ return null;
+ }
+ if ( !$this->matcher->match( $object->getPrelude(), [ 'mark-significance' => true ] ) ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-namespace-value', $cv );
+ } else {
+ $this->sanitizationError( 'missing-namespace-value', $object );
+ }
+ return null;
+ }
+ $object = $this->fixPreludeWhitespace( $object, true );
+
+ return $object;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PageAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PageAtRuleSanitizer.php
new file mode 100644
index 000000000..d64913c12
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PageAtRuleSanitizer.php
@@ -0,0 +1,133 @@
+optionalWhitespace();
+ $pseudoPage = new Juxtaposition( [
+ new TokenMatcher( Token::T_COLON ),
+ new KeywordMatcher( [ 'left', 'right', 'first', 'blank' ] ),
+ ] );
+ $this->pageSelectorMatcher = new Alternative( [
+ Quantifier::hash( new Juxtaposition( [
+ $ows,
+ new Alternative( [
+ Quantifier::plus( $pseudoPage ),
+ new Juxtaposition( [ $matcherFactory->ident(), Quantifier::star( $pseudoPage ) ] ),
+ ] ),
+ $ows,
+ ] ) ),
+ $ows
+ ] );
+ $this->pageSelectorMatcher->setDefaultOptions( [ 'skip-whitespace' => false ] );
+
+ // Clone the $propertySanitizer and inject the special "size" property
+ $this->propertySanitizer = clone( $propertySanitizer );
+ $this->propertySanitizer->addKnownProperties( [ 'size' => new Alternative( [
+ Quantifier::count( $matcherFactory->length(), 1, 2 ),
+ new KeywordMatcher( 'auto' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'A5', 'A4', 'A3', 'B5', 'B4', 'letter', 'legal', 'ledger' ] ),
+ new KeywordMatcher( [ 'portrait', 'landscape' ] ),
+ ] ),
+ ] ) ] );
+
+ $this->ruleSanitizer = new MarginAtRuleSanitizer( $propertySanitizer );
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'page' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'page' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'page' ] );
+ return null;
+ }
+
+ // Test the page selector
+ $match = $this->pageSelectorMatcher->match(
+ $object->getPrelude(), [ 'mark-significance' => true ]
+ );
+ if ( !$match ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() ) ?: $object->getPrelude();
+ $this->sanitizationError( 'invalid-page-selector', $cv );
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+
+ // Parse the block's contents into a list of declarations and at-rules,
+ // sanitize it, and put it back into the block.
+ $blockContents = $ret->getBlock()->getValue();
+ $parser = Parser::newFromTokens( $blockContents->toTokenArray() );
+ $oldList = $parser->parseDeclarationOrAtRuleList();
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $parser->getParseErrors() );
+ $newList = new DeclarationOrAtRuleList();
+ foreach ( $oldList as $thing ) {
+ if ( $thing instanceof Declaration ) {
+ $thing = $this->sanitizeObj( $this->propertySanitizer, $thing );
+ } elseif ( $thing instanceof AtRule && $this->ruleSanitizer->handlesRule( $thing ) ) {
+ $thing = $this->sanitizeObj( $this->ruleSanitizer, $thing );
+ } else {
+ $this->sanitizationError( 'invalid-page-rule-content', $thing );
+ $thing = null;
+ }
+ if ( $thing ) {
+ $newList->add( $thing );
+ }
+ }
+ $blockContents->clear();
+ $blockContents->add( $newList->toComponentValueArray() );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PropertySanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PropertySanitizer.php
new file mode 100644
index 000000000..1076e5742
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PropertySanitizer.php
@@ -0,0 +1,129 @@
+setKnownProperties( $properties );
+ $this->setCssWideKeywordsMatcher( $cssWideKeywordsMatcher ?: new NothingMatcher );
+ }
+
+ /**
+ * Access the list of known properties
+ * @return Matcher[]
+ */
+ public function getKnownProperties() {
+ return $this->knownProperties;
+ }
+
+ /**
+ * Set the list of known properties
+ * @param Matcher[] $properties Array mapping declaration names (lowercase)
+ * to Matchers for the values
+ */
+ public function setKnownProperties( array $properties ) {
+ foreach ( $properties as $prop => $matcher ) {
+ if ( strtolower( $prop ) !== $prop ) {
+ throw new InvalidArgumentException( "Property name '$prop' must be lowercased" );
+ }
+ if ( !$matcher instanceof Matcher ) {
+ throw new InvalidArgumentException( "Value for '$prop' is not a Matcher" );
+ }
+ }
+ $this->knownProperties = $properties;
+ }
+
+ /**
+ * Merge a list of matchers into the list of known properties
+ * @param Matcher[] $properties Array mapping declaration names (lowercase)
+ * to Matchers for the values
+ * @throws InvalidArgumentException if some property is already defined
+ */
+ public function addKnownProperties( $props ) {
+ $dups = [];
+ foreach ( $props as $k => $v ) {
+ if ( isset( $this->knownProperties[$k] ) && $props[$k] !== $this->knownProperties[$k] ) {
+ $dups[] = $k;
+ }
+ }
+ if ( $dups ) {
+ throw new InvalidArgumentException(
+ 'Duplicate definitions for properties: ' . join( ' ', $dups )
+ );
+ }
+ $this->setKnownProperties( $this->knownProperties + $props );
+ }
+
+ /**
+ * Fetch the matcher for keywords that should be recognized for all properties.
+ * @return Matcher
+ */
+ public function getCssWideKeywordsMatcher() {
+ return $this->cssWideKeywords;
+ }
+
+ /**
+ * Set the matcher for keywords that should be recognized for all properties.
+ * @param Matcher $matcher
+ */
+ public function setCssWideKeywordsMatcher( Matcher $matcher ) {
+ $this->cssWideKeywords = $matcher;
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Declaration ) {
+ $this->sanitizationError( 'expected-declaration', $object );
+ return null;
+ }
+
+ $knownProperties = $this->getKnownProperties();
+ $name = strtolower( $object->getName() );
+ if ( !isset( $knownProperties[$name] ) ) {
+ $this->sanitizationError( 'unrecognized-property', $object );
+ return null;
+ }
+
+ $list = $object->getValue();
+ if ( !$knownProperties[$name]->match( $list, [ 'mark-significance' => true ] ) &&
+ !$this->getCssWideKeywordsMatcher()->match( $list, [ 'mark-significance' => true ] )
+ ) {
+ $cv = Util::findFirstNonWhitespace( $list );
+ if ( $cv ) {
+ $this->sanitizationError( 'bad-value-for-property', $cv, [ $name ] );
+ } else {
+ $this->sanitizationError( 'missing-value-for-property', $object, [ $name ] );
+ }
+ return null;
+ }
+
+ return $object;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/RuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/RuleSanitizer.php
new file mode 100644
index 000000000..e77c9184f
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/RuleSanitizer.php
@@ -0,0 +1,113 @@
+getValue();
+ $parser = Parser::newFromTokens( $blockContents->toTokenArray() );
+ $declarations = $parser->parseDeclarationList();
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $parser->getParseErrors() );
+ $declarations = $this->sanitizeList( $sanitizer, $declarations );
+ $blockContents->clear();
+ $blockContents->add( $declarations->toComponentValueArray() );
+ }
+
+ /**
+ * Sanitize a block's contents as a RuleList, in place
+ * @param SimpleBlock $block
+ * @param RuleSanitizer[] $sanitizers
+ */
+ protected function sanitizeRuleBlock( SimpleBlock $block, array $sanitizers ) {
+ $blockContents = $block->getValue();
+ $parser = Parser::newFromTokens( $blockContents->toTokenArray() );
+ $rules = $parser->parseRuleList();
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $parser->getParseErrors() );
+ $rules = $this->sanitizeRules( $sanitizers, $rules );
+ $blockContents->clear();
+ $blockContents->add( $rules->toComponentValueArray() );
+ }
+
+ /**
+ * For the whitespace at the start of the prelude
+ *
+ * The matcher probably marked it insignificant, but it's actually
+ * significant if it's needed to separate the at-keyword and the first
+ * thing in the prelude. And if there isn't a whitespace there, add one if
+ * it would be significant.
+ *
+ * @param AtRule $rule
+ * @param bool $cloneIfNecessary
+ * @return AtRule
+ */
+ protected function fixPreludeWhitespace( AtRule $rule, $cloneIfNecessary ) {
+ $prelude = $rule->getPrelude();
+ if ( !count( $prelude ) ) {
+ return $rule;
+ }
+
+ $cv = Util::findFirstNonWhitespace( $rule->getPrelude() );
+ if ( !$cv ) {
+ foreach ( $prelude as $i => $v ) {
+ if ( $v instanceof Token && $v->significant() ) {
+ $prelude[$i] = $v->copyWithSignificance( false );
+ }
+ }
+ return $rule;
+ }
+
+ $significant = $cv instanceof CSSFunction ||
+ $cv instanceof Token &&
+ Token::separate( new Token( Token::T_AT_KEYWORD, $rule->getName() ), $cv );
+
+ if ( $prelude[0] instanceof Token && $prelude[0]->type() === Token::T_WHITESPACE ) {
+ $prelude[0] = $prelude[0]->copyWithSignificance( $significant );
+ } elseif ( $significant ) {
+ if ( $cloneIfNecessary ) {
+ $rule = clone( $rule );
+ $prelude = $rule->getPrelude();
+ }
+ $prelude->add( new Token( Token::T_WHITESPACE ), 0 );
+ }
+
+ return $rule;
+ }
+
+ /**
+ * Indicate whether this rule is handled by this sanitizer.
+ * @param Rule $rule
+ * @return bool
+ */
+ abstract public function handlesRule( Rule $rule );
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/Sanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/Sanitizer.php
new file mode 100644
index 000000000..e9190962d
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/Sanitizer.php
@@ -0,0 +1,140 @@
+sanitizationErrors;
+ }
+
+ /**
+ * Clear sanitization errors
+ */
+ public function clearSanitizationErrors() {
+ $this->sanitizationErrors = [];
+ }
+
+ /**
+ * Record a sanitization error
+ * @param string $tag Error tag
+ * @param CSSObject $object Report the error starting at this object
+ * @param array $data Extra data about the error.
+ */
+ protected function sanitizationError( $tag, CSSObject $object, array $data = [] ) {
+ list( $line, $pos ) = $object->getPosition();
+ $this->sanitizationErrors[] = array_merge( [ $tag, $line, $pos ], $data );
+ }
+
+ /**
+ * Run another sanitizer over a CSSObject
+ * @param Sanitizer $sanitizer
+ * @param CSSObject $object
+ * @return CSSObject|null
+ */
+ protected function sanitizeObj( Sanitizer $sanitizer, CSSObject $object ) {
+ $newObj = $sanitizer->doSanitize( $object );
+ $errors = $sanitizer->getSanitizationErrors();
+ if ( $errors && $sanitizer !== $this ) {
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $errors );
+ $sanitizer->clearSanitizationErrors();
+ }
+ return $newObj;
+ }
+
+ /**
+ * Run a sanitizer over all CSSObjects in a CSSObjectList
+ * @param Sanitizer $sanitizer
+ * @param CSSObjectList $list
+ * @return CSSObjectList
+ */
+ protected function sanitizeList( Sanitizer $sanitizer, CSSObjectList $list ) {
+ $class = get_class( $list );
+ $ret = new $class;
+ foreach ( $list as $obj ) {
+ $newObj = $sanitizer->doSanitize( $obj );
+ if ( $newObj ) {
+ $ret->add( $newObj );
+ }
+ }
+
+ $errors = $sanitizer->getSanitizationErrors();
+ if ( $errors && $sanitizer !== $this ) {
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $errors );
+ $sanitizer->clearSanitizationErrors();
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Run a set of RuleSanitizers over all rules in a RuleList
+ * @param RuleSanitizer[] $ruleSanitizers
+ * @param RuleList $list
+ * @return RuleList
+ */
+ protected function sanitizeRules( array $ruleSanitizers, RuleList $list ) {
+ $ret = new RuleList();
+ $curIndex = -INF;
+ foreach ( $list as $rule ) {
+ foreach ( $ruleSanitizers as $sanitizer ) {
+ if ( $sanitizer->handlesRule( $rule ) ) {
+ $indexes = $sanitizer->getIndex();
+ if ( is_array( $indexes ) ) {
+ list( $testIndex, $setIndex ) = $indexes;
+ } else {
+ $testIndex = $setIndex = $indexes;
+ }
+ if ( $testIndex < $curIndex ) {
+ $this->sanitizationError( 'misordered-rule', $rule );
+ } else {
+ $curIndex = $setIndex;
+ $rule = $this->sanitizeObj( $sanitizer, $rule );
+ if ( $rule ) {
+ $ret->add( $rule );
+ }
+ }
+ continue 2;
+ }
+ }
+ $this->sanitizationError( 'unrecognized-rule', $rule );
+ }
+ return $ret;
+ }
+
+ /**
+ * Sanitize a CSS object
+ * @param CSSObject $object
+ * @return CSSObject|null Sanitized version of the object, or null if
+ * sanitization failed
+ */
+ abstract protected function doSanitize( CSSObject $object );
+
+ /**
+ * Sanitize a CSS object
+ * @param CSSObject $object
+ * @return CSSObject|null Sanitized version of the object, or null if
+ * sanitization failed
+ */
+ public function sanitize( CSSObject $object ) {
+ return $this->doSanitize( $object );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleAttributeSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleAttributeSanitizer.php
new file mode 100644
index 000000000..d5d6e5dd7
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleAttributeSanitizer.php
@@ -0,0 +1,70 @@
+`)
+ * @see https://www.w3.org/TR/2013/REC-css-style-attr-20131107/
+ */
+class StyleAttributeSanitizer extends Sanitizer {
+
+ /** @var Sanitizer */
+ protected $propertySanitizer;
+
+ /**
+ * @param PropertySanitizer $propertySanitizer Sanitizer to test property declarations.
+ * Probably an instance of StylePropertySanitizer.
+ */
+ public function __construct( PropertySanitizer $propertySanitizer ) {
+ $this->propertySanitizer = $propertySanitizer;
+ }
+
+ /**
+ * Create and return a default StyleAttributeSanitizer.
+ * @note This method exists more to be an example of how to put everything
+ * together than to be used directly.
+ * @return StyleAttributeSanitizer
+ */
+ public static function newDefault() {
+ // First, we need a matcher factory for the stuff all the sanitizers
+ // will need.
+ $matcherFactory = MatcherFactory::singleton();
+
+ // This is the sanitizer for a single "property: value"
+ $propertySanitizer = new StylePropertySanitizer( $matcherFactory );
+
+ // StyleAttributeSanitizer brings it all together
+ $sanitizer = new StyleAttributeSanitizer( $propertySanitizer );
+
+ return $sanitizer;
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof DeclarationList ) {
+ $this->sanitizationError( 'expected-declaration-list', $object );
+ return null;
+ }
+ return $this->sanitizeList( $this->propertySanitizer, $object );
+ }
+
+ /**
+ * Sanitize a string value.
+ * @param string $string
+ * @return DeclarationList
+ */
+ public function sanitizeString( $string ) {
+ $parser = Parser::newFromString( $string );
+ $declarations = $parser->parseDeclarationList();
+ $this->sanitizationErrors = array_merge( $this->sanitizationErrors, $parser->getParseErrors() );
+ return $this->sanitizeList( $this->propertySanitizer, $declarations );
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylePropertySanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylePropertySanitizer.php
new file mode 100644
index 000000000..3546f7071
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylePropertySanitizer.php
@@ -0,0 +1,1798 @@
+cssWideKeywords() );
+
+ $this->addKnownProperties( [
+ // https://www.w3.org/TR/2016/CR-css-cascade-3-20160519/#all-shorthand
+ 'all' => $matcherFactory->cssWideKeywords(),
+
+ // https://www.w3.org/TR/2015/REC-pointerevents-20150224/#the-touch-action-css-property
+ 'touch-action' => new Alternative( [
+ new KeywordMatcher( [ 'auto', 'none', 'manipulation' ] ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'pan-x' ),
+ new KeywordMatcher( 'pan-y' ),
+ ] ),
+ ] ),
+
+ // https://www.w3.org/TR/2013/WD-css3-page-20130314/#using-named-pages
+ 'page' => $matcherFactory->ident(),
+ ] );
+ $this->addKnownProperties( $this->css2( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssDisplay3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssPosition3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssColor3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssBorderBackground3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssImages3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssFonts3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssMulticol( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssOverflow3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssUI4( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssCompositing1( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssWritingModes3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssTransitions( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssAnimations( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssFlexbox3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssTransforms1( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssText3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssTextDecor3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssAlign3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssBreak3( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssSpeech( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssGrid1( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssFilter1( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssShapes1( $matcherFactory ) );
+ $this->addKnownProperties( $this->cssMasking1( $matcherFactory ) );
+ }
+
+ /**
+ * Properties from CSS 2.1
+ * @see https://www.w3.org/TR/2011/REC-CSS2-20110607/
+ * @note Omits properties that have been replaced by a CSS3 module
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function css2( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $none = new KeywordMatcher( 'none' );
+ $auto = new KeywordMatcher( 'auto' );
+ $autoLength = new Alternative( [ $auto, $matcherFactory->length() ] );
+ $autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/box.html
+ $props['margin-top'] = $autoLengthPct;
+ $props['margin-bottom'] = $autoLengthPct;
+ $props['margin-left'] = $autoLengthPct;
+ $props['margin-right'] = $autoLengthPct;
+ $props['margin'] = Quantifier::count( $autoLengthPct, 1, 4 );
+ $props['padding-top'] = $matcherFactory->lengthPercentage();
+ $props['padding-bottom'] = $matcherFactory->lengthPercentage();
+ $props['padding-left'] = $matcherFactory->lengthPercentage();
+ $props['padding-right'] = $matcherFactory->lengthPercentage();
+ $props['padding'] = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/visuren.html
+ $props['float'] = new KeywordMatcher( [ 'left', 'right', 'none' ] );
+ $props['clear'] = new KeywordMatcher( [ 'none', 'left', 'right', 'both' ] );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/visudet.html
+ $props['width'] = $autoLengthPct;
+ $props['min-width'] = $matcherFactory->lengthPercentage();
+ $props['max-width'] = new Alternative( [ $none, $matcherFactory->lengthPercentage() ] );
+ $props['height'] = $autoLengthPct;
+ $props['min-height'] = $matcherFactory->lengthPercentage();
+ $props['max-height'] = $props['max-width'];
+ $props['line-height'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ $matcherFactory->length(),
+ $matcherFactory->numberPercentage(),
+ ] );
+ $props['vertical-align'] = new Alternative( [
+ new KeywordMatcher( [
+ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle', 'bottom', 'text-bottom'
+ ] ),
+ $matcherFactory->lengthPercentage(),
+ ] );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/visufx.html
+ $props['clip'] = new Alternative( [
+ $auto, new FunctionMatcher( 'rect', Quantifier::hash( $autoLength, 4, 4 ) ),
+ ] );
+ $props['visibility'] = new KeywordMatcher( [ 'visible', 'hidden', 'collapse' ] );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/generate.html
+ $props['list-style-type'] = new KeywordMatcher( [
+ 'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero', 'lower-roman', 'upper-roman',
+ 'lower-greek', 'lower-latin', 'upper-latin', 'armenian', 'georgian', 'lower-alpha',
+ 'upper-alpha', 'none'
+ ] );
+ $props['content'] = new Alternative( [
+ new KeywordMatcher( [ 'normal', 'none' ] ),
+ Quantifier::plus( new Alternative( [
+ $matcherFactory->string(),
+ $matcherFactory->image(), // Replaces per https://www.w3.org/TR/css3-images/#placement
+ new FunctionMatcher( 'counter', new Juxtaposition( [
+ $matcherFactory->ident(),
+ Quantifier::optional( $props['list-style-type'] ),
+ ], true ) ),
+ new FunctionMatcher( 'counters', new Juxtaposition( [
+ $matcherFactory->ident(),
+ $matcherFactory->string(),
+ Quantifier::optional( $props['list-style-type'] ),
+ ], true ) ),
+ new FunctionMatcher( 'attr', $matcherFactory->ident() ),
+ new KeywordMatcher( [ 'open-quote', 'close-quote', 'no-open-quote', 'no-close-quote' ] ),
+ ] ) )
+ ] );
+ $props['quotes'] = new Alternative( [
+ $none, Quantifier::plus( new Juxtaposition( [
+ $matcherFactory->string(), $matcherFactory->string()
+ ] ) ),
+ ] );
+ $props['counter-reset'] = new Alternative( [
+ $none,
+ Quantifier::plus( new Juxtaposition( [
+ $matcherFactory->ident(), Quantifier::optional( $matcherFactory->integer() )
+ ] ) ),
+ ] );
+ $props['counter-increment'] = $props['counter-reset'];
+ $props['list-style-image'] = new Alternative( [
+ $none,
+ $matcherFactory->image() // Replaces per https://www.w3.org/TR/css3-images/#placement
+ ] );
+ $props['list-style-position'] = new KeywordMatcher( [ 'inside', 'outside' ] );
+ $props['list-style'] = UnorderedGroup::someOf( [
+ $props['list-style-type'], $props['list-style-position'], $props['list-style-image']
+ ] );
+
+ // https://www.w3.org/TR/2011/REC-CSS2-20110607/tables.html
+ $props['caption-side'] = new KeywordMatcher( [ 'top', 'bottom' ] );
+ $props['table-layout'] = new KeywordMatcher( [ 'auto', 'fixed' ] );
+ $props['border-collapse'] = new KeywordMatcher( [ 'collapse', 'separate' ] );
+ $props['border-spacing'] = Quantifier::count( $matcherFactory->length(), 1, 2 );
+ $props['empty-cells'] = new KeywordMatcher( [ 'show', 'hide' ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Display Module Level 3
+ * @see https://www.w3.org/TR/2017/WD-css-display-3-20170126/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssDisplay3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['display'] = new Alternative( [
+ UnorderedGroup::someOf( [ // ||
+ new KeywordMatcher( [ 'block', 'inline', 'run-in' ] ),
+ new KeywordMatcher( [ 'flow', 'flow-root', 'table', 'flex', 'grid', 'ruby' ] ),
+ ] ),
+ UnorderedGroup::allOf( [ //
+ new KeywordMatcher( 'list-item' ),
+ Quantifier::optional( new KeywordMatcher( [ 'block', 'inline', 'run-in' ] ) ),
+ Quantifier::optional( new KeywordMatcher( [ 'flow', 'flow-root' ] ) ),
+ ] ),
+ new KeywordMatcher( [
+ //
+ 'table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-cell',
+ 'table-column-group', 'table-column', 'table-caption', 'ruby-base', 'ruby-text',
+ 'ruby-base-container', 'ruby-text-container',
+ //
+ 'contents', 'none',
+ //
+ 'inline-block', 'inline-list-item', 'inline-table', 'inline-flex', 'inline-grid',
+ // https://www.w3.org/TR/2017/CR-css-grid-1-20170209/
+ 'subgrid',
+ ] ),
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Positioned Layout Module Level 3
+ * @see https://www.w3.org/TR/2016/WD-css-position-3-20160517/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssPosition3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $auto = new KeywordMatcher( 'auto' );
+ $autoLengthPct = new Alternative( [ $auto, $matcherFactory->lengthPercentage() ] );
+
+ $props = [];
+
+ $props['position'] = new KeywordMatcher( [
+ 'static', 'relative', 'absolute', 'sticky', 'fixed'
+ ] );
+ $props['top'] = $autoLengthPct;
+ $props['right'] = $autoLengthPct;
+ $props['bottom'] = $autoLengthPct;
+ $props['left'] = $autoLengthPct;
+ $props['offset-before'] = $autoLengthPct;
+ $props['offset-after'] = $autoLengthPct;
+ $props['offset-start'] = $autoLengthPct;
+ $props['offset-end'] = $autoLengthPct;
+ $props['z-index'] = new Alternative( [ $auto, $matcherFactory->integer() ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Color Module Level 3
+ * @see https://www.w3.org/TR/2011/REC-css3-color-20110607/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssColor3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $props['color'] = $matcherFactory->color();
+ $props['opacity'] = $matcherFactory->number();
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Data types for backgrounds
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return array
+ */
+ protected function backgroundTypes( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $types = [];
+
+ $types['bgrepeat'] = new Alternative( [
+ new KeywordMatcher( [ 'repeat-x', 'repeat-y' ] ),
+ Quantifier::count( new KeywordMatcher( [ 'repeat', 'space', 'round', 'no-repeat' ] ), 1, 2 ),
+ ] );
+ $types['bgsize'] = new Alternative( [
+ Quantifier::count( new Alternative( [
+ $matcherFactory->lengthPercentage(),
+ new KeywordMatcher( 'auto' )
+ ] ), 1, 2 ),
+ new KeywordMatcher( [ 'cover', 'contain' ] )
+ ] );
+ $types['boxKeywords'] = [ 'border-box', 'padding-box', 'content-box' ];
+
+ $this->cache[__METHOD__] = $types;
+ return $types;
+ }
+
+ /**
+ * Properties for CSS Backgrounds and Borders Module Level 3
+ * @see https://www.w3.org/TR/2014/CR-css3-background-20140909/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssBorderBackground3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $types = $this->backgroundTypes( $matcherFactory );
+ $slash = new DelimMatcher( '/' );
+ $bgimage = new Alternative( [ new KeywordMatcher( 'none' ), $matcherFactory->image() ] );
+ $bgrepeat = $types['bgrepeat'];
+ $bgattach = new KeywordMatcher( [ 'scroll', 'fixed', 'local' ] );
+ $position = $matcherFactory->position();
+ $box = new KeywordMatcher( $types['boxKeywords'] );
+ $bgsize = $types['bgsize'];
+ $bglayer = UnorderedGroup::someOf( [
+ $bgimage,
+ new Juxtaposition( [
+ $position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
+ ] ),
+ $bgrepeat,
+ $bgattach,
+ $box,
+ $box,
+ ] );
+ $finalBglayer = UnorderedGroup::someOf( [
+ $bgimage,
+ new Juxtaposition( [
+ $position, Quantifier::optional( new Juxtaposition( [ $slash, $bgsize ] ) )
+ ] ),
+ $bgrepeat,
+ $bgattach,
+ $box,
+ $box,
+ $matcherFactory->color(),
+ ] );
+
+ $props['background-color'] = $matcherFactory->color();
+ $props['background-image'] = Quantifier::hash( $bgimage );
+ $props['background-repeat'] = Quantifier::hash( $bgrepeat );
+ $props['background-attachment'] = Quantifier::hash( $bgattach );
+ $props['background-position'] = Quantifier::hash( $position );
+ $props['background-clip'] = Quantifier::hash( $box );
+ $props['background-origin'] = $props['background-clip'];
+ $props['background-size'] = Quantifier::hash( $bgsize );
+ $props['background'] = new Juxtaposition(
+ [ Quantifier::hash( $bglayer, 0, INF ), $finalBglayer ], true
+ );
+
+ $lineStyle = new KeywordMatcher( [
+ 'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'
+ ] );
+ $lineWidth = new Alternative( [
+ new KeywordMatcher( [ 'thin', 'medium', 'thick' ] ), $matcherFactory->length(),
+ ] );
+ $borderCombo = UnorderedGroup::someOf( [ $lineWidth, $lineStyle, $matcherFactory->color() ] );
+ $radius = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 2 );
+ $radius4 = Quantifier::count( $matcherFactory->lengthPercentage(), 1, 4 );
+
+ $props['border-top-color'] = $matcherFactory->color();
+ $props['border-right-color'] = $matcherFactory->color();
+ $props['border-bottom-color'] = $matcherFactory->color();
+ $props['border-left-color'] = $matcherFactory->color();
+ $props['border-color'] = Quantifier::count( $matcherFactory->color(), 1, 4 );
+ $props['border-top-style'] = $lineStyle;
+ $props['border-right-style'] = $lineStyle;
+ $props['border-bottom-style'] = $lineStyle;
+ $props['border-left-style'] = $lineStyle;
+ $props['border-style'] = Quantifier::count( $lineStyle, 1, 4 );
+ $props['border-top-width'] = $lineWidth;
+ $props['border-right-width'] = $lineWidth;
+ $props['border-bottom-width'] = $lineWidth;
+ $props['border-left-width'] = $lineWidth;
+ $props['border-width'] = Quantifier::count( $lineWidth, 1, 4 );
+ $props['border-top'] = $borderCombo;
+ $props['border-right'] = $borderCombo;
+ $props['border-bottom'] = $borderCombo;
+ $props['border-left'] = $borderCombo;
+ $props['border'] = $borderCombo;
+ $props['border-top-left-radius'] = $radius;
+ $props['border-top-right-radius'] = $radius;
+ $props['border-bottom-left-radius'] = $radius;
+ $props['border-bottom-right-radius'] = $radius;
+ $props['border-radius'] = new Juxtaposition( [
+ $radius4, Quantifier::optional( new Juxtaposition( [ $slash, $radius4 ] ) )
+ ] );
+ $props['border-image-source'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ $matcherFactory->image()
+ ] );
+ $props['border-image-slice'] = UnorderedGroup::allOf( [
+ Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
+ Quantifier::optional( new KeywordMatcher( 'fill' ) ),
+ ] );
+ $props['border-image-width'] = Quantifier::count( new Alternative( [
+ $matcherFactory->length(),
+ $matcherFactory->percentage(),
+ $matcherFactory->number(),
+ new KeywordMatcher( 'auto' ),
+ ] ), 1, 4 );
+ $props['border-image-outset'] = Quantifier::count( new Alternative( [
+ $matcherFactory->length(),
+ $matcherFactory->number(),
+ ] ), 1, 4 );
+ $props['border-image-repeat'] = Quantifier::count( new KeywordMatcher( [
+ 'stretch', 'repeat', 'round', 'space'
+ ] ), 1, 2 );
+ $props['border-image'] = UnorderedGroup::someOf( [
+ $props['border-image-source'],
+ new Juxtaposition( [
+ $props['border-image-slice'],
+ Quantifier::optional( new Alternative( [
+ new Juxtaposition( [ $slash, $props['border-image-width'] ] ),
+ new Juxtaposition( [
+ $slash,
+ Quantifier::optional( $props['border-image-width'] ),
+ $slash,
+ $props['border-image-outset']
+ ] )
+ ] ) )
+ ] ),
+ $props['border-image-repeat']
+ ] );
+
+ $props['box-shadow'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ Quantifier::hash( UnorderedGroup::allOf( [
+ Quantifier::optional( new KeywordMatcher( 'inset' ) ),
+ Quantifier::count( $matcherFactory->length(), 2, 4 ),
+ Quantifier::optional( $matcherFactory->color() ),
+ ] ) )
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Image Values and Replaced Content Module Level 3
+ * @see https://www.w3.org/TR/2012/CR-css3-images-20120417/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssImages3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['object-fit'] = new KeywordMatcher( [ 'fill', 'contain', 'cover', 'none', 'scale-down' ] );
+ $props['object-position'] = $matcherFactory->position();
+ $props['image-resolution'] = UnorderedGroup::allOf( [
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'from-image' ),
+ $matcherFactory->resolution(),
+ ] ),
+ Quantifier::optional( new KeywordMatcher( 'snap' ) )
+ ] );
+ $props['image-orientation'] = $matcherFactory->angle();
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Fonts Module Level 3
+ * @see https://www.w3.org/TR/2013/CR-css-fonts-3-20131003/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssFonts3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $css2 = $this->css2( $matcherFactory );
+ $props = [];
+
+ $matchData = FontFaceAtRuleSanitizer::fontMatchData( $matcherFactory );
+
+ // Note: is syntactically a subset of ,
+ // so no point in separately listing it.
+ $props['font-family'] = Quantifier::hash( $matchData['familyName'] );
+ $props['font-weight'] = new Alternative( [
+ new KeywordMatcher( [ 'normal', 'bold', 'bolder', 'lighter' ] ),
+ new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+ return $t->typeFlag() === 'integer' && preg_match( '/^[1-9]00$/', $t->representation() );
+ } ),
+ ] );
+ $props['font-stretch'] = $matchData['font-stretch'];
+ $props['font-style'] = $matchData['font-style'];
+ $props['font-size'] = new Alternative( [
+ new KeywordMatcher( [
+ 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'larger', 'smaller'
+ ] ),
+ $matcherFactory->lengthPercentage(),
+ ] );
+ $props['font-size-adjust'] = new Alternative( [
+ new KeywordMatcher( 'none' ), $matcherFactory->number()
+ ] );
+ $props['font'] = new Alternative( [
+ new Juxtaposition( [
+ Quantifier::optional( UnorderedGroup::someOf( [
+ $props['font-style'],
+ new KeywordMatcher( [ 'normal', 'small-caps' ] ),
+ $props['font-weight'],
+ $props['font-stretch'],
+ ] ) ),
+ $props['font-size'],
+ Quantifier::optional( new Juxtaposition( [
+ new DelimMatcher( '/' ),
+ $css2['line-height'],
+ ] ) ),
+ $props['font-family'],
+ ] ),
+ new KeywordMatcher( [ 'caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar' ] )
+ ] );
+ $props['font-synthesis'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'weight' ),
+ new KeywordMatcher( 'style' ),
+ ] )
+ ] );
+ $props['font-kerning'] = new KeywordMatcher( [ 'auto', 'normal', 'none' ] );
+ $props['font-variant-ligatures'] = new Alternative( [
+ new KeywordMatcher( [ 'normal', 'none' ] ),
+ UnorderedGroup::someOf( $matchData['ligatures'] )
+ ] );
+ $props['font-variant-position'] = new KeywordMatcher( [ 'normal', 'sub', 'super' ] );
+ $props['font-variant-caps'] = new KeywordMatcher(
+ array_merge( [ 'normal' ], $matchData['capsKeywords'] )
+ );
+ $props['font-variant-numeric'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ UnorderedGroup::someOf( $matchData['numeric'] )
+ ] );
+ $props['font-variant-alternates'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ UnorderedGroup::someOf( $matchData['alt'] )
+ ] );
+ $props['font-variant-east-asian'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ UnorderedGroup::someOf( $matchData['eastAsian'] )
+ ] );
+ $props['font-variant'] = $matchData['font-variant'];
+ $props['font-feature-settings'] = $matchData['font-feature-settings'];
+ $props['font-language-override'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ new TokenMatcher( Token::T_STRING, function ( Token $t ) {
+ return preg_match( '/^[A-Z]{3}$/', $t->value() );
+ } ),
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Multi-column Layout Module
+ * @see https://www.w3.org/TR/2011/CR-css3-multicol-20110412/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssMulticol( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $borders = $this->cssBorderBackground3( $matcherFactory );
+ $breaks = $this->cssBreak3( $matcherFactory );
+ $props = [];
+
+ $auto = new KeywordMatcher( 'auto' );
+ $normal = new KeywordMatcher( 'normal' );
+
+ $props['column-width'] = new Alternative( [ $matcherFactory->length(), $auto ] );
+ $props['column-count'] = new Alternative( [ $matcherFactory->integer(), $auto ] );
+ $props['columns'] = UnorderedGroup::someOf( [ $props['column-width'], $props['column-count'] ] );
+ $props['column-gap'] = new Alternative( [ $matcherFactory->length(), $normal ] );
+ // Copy these from similar items in the Border module
+ $props['column-rule-color'] = $borders['border-right-color'];
+ $props['column-rule-style'] = $borders['border-right-style'];
+ $props['column-rule-width'] = $borders['border-right-width'];
+ $props['column-rule'] = $borders['border-right'];
+ $props['column-span'] = new KeywordMatcher( [ 'none', 'all' ] );
+ $props['column-fill'] = new KeywordMatcher( [ 'auto', 'balance' ] );
+
+ // Copy these from cssBreak3(), the duplication is allowed as long as
+ // they're the identical Matcher object.
+ $props['break-before'] = $breaks['break-before'];
+ $props['break-after'] = $breaks['break-after'];
+ $props['break-inside'] = $breaks['break-inside'];
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Overflow Module Level 3
+ * @see https://www.w3.org/TR/2016/WD-css-overflow-3-20160531/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssOverflow3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['overflow'] = new KeywordMatcher( [ 'visible', 'hidden', 'clip', 'scroll', 'auto' ] );
+ $props['overflow-x'] = $props['overflow'];
+ $props['overflow-y'] = $props['overflow'];
+ $props['max-lines'] = new Alternative( [
+ new KeywordMatcher( 'none' ), $matcherFactory->integer()
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Basic User Interface Module Level 4
+ * @see https://www.w3.org/TR/2017/CR-css-ui-3-20170302/
+ * @see https://www.w3.org/TR/2015/WD-css-ui-4-20150922/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssUI4( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $border = $this->cssBorderBackground3( $matcherFactory );
+ $props = [];
+
+ $props['box-sizing'] = new KeywordMatcher( [ 'content-box', 'border-box' ] );
+ // Copy these from similar border properties
+ $props['outline-width'] = $border['border-top-width'];
+ $props['outline-style'] = new Alternative( [
+ new KeywordMatcher( 'auto' ), $border['border-top-style']
+ ] );
+ $props['outline-color'] = new Alternative( [
+ new KeywordMatcher( 'invert' ), $matcherFactory->color()
+ ] );
+ $props['outline'] = UnorderedGroup::someOf( [
+ $props['outline-width'], $props['outline-style'], $props['outline-color']
+ ] );
+ $props['outline-offset'] = $matcherFactory->length();
+ $props['resize'] = new KeywordMatcher( [ 'none', 'both', 'horizontal', 'vertical' ] );
+ $props['text-overflow'] = Quantifier::count( new Alternative( [
+ new KeywordMatcher( [ 'clip', 'ellipsis', 'fade' ] ),
+ new FunctionMatcher( 'fade', $matcherFactory->lengthPercentage() ),
+ // Including and count that were removed in the latest UI3
+ // but added in the UI4 editor's draft.
+ $matcherFactory->string(),
+ ] ), 1, 2 );
+ $props['cursor'] = new Juxtaposition( [
+ Quantifier::star( new Juxtaposition( [
+ $matcherFactory->image(),
+ Quantifier::optional( new Juxtaposition( [
+ $matcherFactory->number(), $matcherFactory->number()
+ ] ) ),
+ $matcherFactory->comma(),
+ ] ) ),
+ new KeywordMatcher( [
+ 'auto', 'default', 'none', 'context-menu', 'help', 'pointer', 'progress', 'wait', 'cell',
+ 'crosshair', 'text', 'vertical-text', 'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab',
+ 'grabbing', 'e-resize', 'n-resize', 'ne-resize', 'nw-resize', 's-resize', 'se-resize',
+ 'sw-resize', 'w-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'col-resize',
+ 'row-resize', 'all-scroll', 'zoom-in', 'zoom-out',
+ ] ),
+ ] );
+ $props['caret-color'] = new Alternative( [
+ new KeywordMatcher( 'auto' ), $matcherFactory->color()
+ ] );
+ // Skipping caret-animation, it has been removed in the latest editor's draft
+ $props['caret-shape'] = new KeywordMatcher( [ 'auto', 'bar', 'block', 'underscore' ] );
+ $props['caret'] = UnorderedGroup::someOf( [ $props['caret-color'], $props['caret-shape'] ] );
+ $props['nav-up'] = new Alternative( [
+ new KeywordMatcher( 'auto' ),
+ new Juxtaposition( [
+ $matcherFactory->cssID(),
+ Quantifier::optional( new Alternative( [
+ new KeywordMatcher( [ 'current', 'root' ] ),
+ $matcherFactory->string(),
+ ] ) )
+ ] )
+ ] );
+ $props['nav-right'] = $props['nav-up'];
+ $props['nav-down'] = $props['nav-up'];
+ $props['nav-left'] = $props['nav-up'];
+
+ $props['user-select'] = new KeywordMatcher( [ 'auto', 'text', 'none', 'contain', 'all' ] );
+ // Seems potentially useful enough to let the prefixed versions work.
+ $props['-moz-user-select'] = $props['user-select'];
+ $props['-ms-user-select'] = $props['user-select'];
+ $props['-webkit-user-select'] = $props['user-select'];
+
+ $props['appearance'] = new KeywordMatcher( [ 'auto', 'none' ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Compositing and Blending Level 1
+ * @see https://www.w3.org/TR/2015/CR-compositing-1-20150113/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssCompositing1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['mix-blend-mode'] = new KeywordMatcher( [
+ 'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn',
+ 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'
+ ] );
+ $props['isolation'] = new KeywordMatcher( [ 'auto', 'isolate' ] );
+
+ // The linked spec incorrectly has this without the hash, despite the
+ // textual description and examples showing it as such. The draft has it fixed.
+ $props['background-blend-mode'] = Quantifier::hash( $props['mix-blend-mode'] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Writing Modes Level 3
+ * @see https://www.w3.org/TR/2015/CR-css-writing-modes-3-20151215/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssWritingModes3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['direction'] = new KeywordMatcher( [ 'ltr', 'rtl' ] );
+ $props['unicode-bidi'] = new KeywordMatcher( [
+ 'normal', 'embed', 'isolate', 'bidi-override', 'isolate-override', 'plaintext'
+ ] );
+ $props['writing-mode'] = new KeywordMatcher( [
+ 'horizontal-tb', 'vertical-rl', 'vertical-lr', 'sideways-rl', 'sideways-lr'
+ ] );
+ $props['text-orientation'] = new KeywordMatcher( [ 'mixed', 'upright', 'sideways' ] );
+ $props['text-combine-upright'] = new Alternative( [
+ new KeywordMatcher( [ 'none', 'all' ] ),
+ new Juxtaposition( [
+ new KeywordMatcher( 'digits' ),
+ Quantifier::optional( $matcherFactory->integer() )
+ ] )
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Transitions and animations share these functions
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher
+ */
+ protected function transitionTimingFunction( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $timingFunction = new Alternative( [
+ new KeywordMatcher( [
+ 'ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end'
+ ] ),
+ new FunctionMatcher( 'steps', new Juxtaposition( [
+ $matcherFactory->integer(),
+ Quantifier::optional( new KeywordMatcher( [ 'start', 'end' ] ) ),
+ ], true ) ),
+ new FunctionMatcher( 'cubic-bezier', Quantifier::hash( $matcherFactory->number(), 4, 4 ) ),
+ ] );
+
+ $this->cache[__METHOD__] = $timingFunction;
+ return $timingFunction;
+ }
+
+ /**
+ * Properties for CSS Transitions
+ * @see https://www.w3.org/TR/2013/WD-css3-transitions-20131119/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssTransitions( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $none = new KeywordMatcher( 'none' );
+ $timingFunction = $this->transitionTimingFunction( $matcherFactory );
+
+ $props['transition-property'] = new Alternative( [
+ $none, Quantifier::hash( $matcherFactory->ident() )
+ ] );
+ $props['transition-duration'] = Quantifier::hash( $matcherFactory->time() );
+ $props['transition-timing-function'] = Quantifier::hash( $timingFunction );
+ $props['transition-delay'] = Quantifier::hash( $matcherFactory->time() );
+ $props['transition'] = Quantifier::hash( UnorderedGroup::someOf( [
+ $matcherFactory->ident(), // none and are grammatically the same
+ $matcherFactory->time(),
+ $timingFunction,
+ $matcherFactory->time(),
+ ] ) );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Animations
+ * @see https://www.w3.org/TR/2013/WD-css3-animations-20130219/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssAnimations( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $timingFunction = $this->transitionTimingFunction( $matcherFactory );
+ $count = new Alternative( [
+ new KeywordMatcher( 'infinite' ),
+ $matcherFactory->number()
+ ] );
+ $direction = new KeywordMatcher( [ 'normal', 'reverse', 'alternate', 'alternate-reverse' ] );
+ $playState = new KeywordMatcher( [ 'running', 'paused' ] );
+ $fillMode = new KeywordMatcher( [ 'none', 'forwards', 'backwards', 'both' ] );
+
+ $props['animation-name'] = Quantifier::hash( $matcherFactory->ident() );
+ $props['animation-duration'] = Quantifier::hash( $matcherFactory->time() );
+ $props['animation-timing-function'] = Quantifier::hash( $timingFunction );
+ $props['animation-iteration-count'] = Quantifier::hash( $count );
+ $props['animation-direction'] = Quantifier::hash( $direction );
+ $props['animation-play-state'] = Quantifier::hash( $playState );
+ $props['animation-delay'] = Quantifier::hash( $matcherFactory->time() );
+ $props['animation-fill-mode'] = Quantifier::hash( $fillMode );
+ $props['animation'] = Quantifier::hash( UnorderedGroup::someOf( [
+ $matcherFactory->ident(),
+ $matcherFactory->time(),
+ $timingFunction,
+ $matcherFactory->time(),
+ $count,
+ $direction,
+ $fillMode,
+ $playState
+ ] ) );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Flexible Box Layout Module Level 1
+ * @see https://www.w3.org/TR/2016/CR-css-flexbox-1-20160526/
+ * @note Omits align-* and justify-* properties redefined by self::cssAlign3()
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssFlexbox3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $props['flex-direction'] = new KeywordMatcher( [
+ 'row', 'row-reverse', 'column', 'column-reverse'
+ ] );
+ $props['flex-wrap'] = new KeywordMatcher( [ 'nowrap', 'wrap', 'wrap-reverse' ] );
+ $props['flex-flow'] = UnorderedGroup::someOf( [ $props['flex-direction'], $props['flex-wrap'] ] );
+ $props['order'] = $matcherFactory->integer();
+ $props['flex-grow'] = $matcherFactory->number();
+ $props['flex-shrink'] = $matcherFactory->number();
+ $props['flex-basis'] = new Alternative( [
+ new KeywordMatcher( [ 'content', 'auto' ] ),
+ $matcherFactory->lengthPercentage(),
+ ] );
+ $props['flex'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new Juxtaposition( [ $props['flex-grow'], Quantifier::optional( $props['flex-shrink'] ) ] ),
+ $props['flex-basis'],
+ ] )
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Transforms Module Level 1
+ * @see https://www.w3.org/TR/2013/WD-css-transforms-1-20131126/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssTransforms1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $a = $matcherFactory->angle();
+ $n = $matcherFactory->number();
+ $l = $matcherFactory->length();
+ $v = new Alternative( [ $l, $n ] );
+ $lp = $matcherFactory->lengthPercentage();
+ $olp = Quantifier::optional( $lp );
+ $center = new KeywordMatcher( 'center' );
+ $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
+ $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
+
+ $props['transform'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ Quantifier::plus( new Alternative( [
+ new FunctionMatcher( 'matrix', Quantifier::hash( $n, 6, 6 ) ),
+ new FunctionMatcher( 'translate', Quantifier::hash( $v, 1, 2 ) ),
+ new FunctionMatcher( 'translateX', $v ),
+ new FunctionMatcher( 'translateY', $v ),
+ new FunctionMatcher( 'scale', Quantifier::hash( $n, 1, 2 ) ),
+ new FunctionMatcher( 'scaleX', $n ),
+ new FunctionMatcher( 'scaleY', $n ),
+ new FunctionMatcher( 'rotate', $a ),
+ new FunctionMatcher( 'skew', Quantifier::hash( $a, 1, 2 ) ),
+ new FunctionMatcher( 'skewX', $a ),
+ new FunctionMatcher( 'skewY', $a ),
+ new FunctionMatcher( 'matrix3d', Quantifier::hash( $n, 16, 16 ) ),
+ new FunctionMatcher( 'translate3d', new Juxtaposition( [ $v, $v, $l ], true ) ),
+ new FunctionMatcher( 'translateZ', $l ),
+ new FunctionMatcher( 'scale3d', Quantifier::hash( $n, 3, 3 ) ),
+ new FunctionMatcher( 'scaleZ', $n ),
+ new FunctionMatcher( 'rotate3d', new Juxtaposition( [ $n, $n, $n, $a ], true ) ),
+ new FunctionMatcher( 'rotateX', $a ),
+ new FunctionMatcher( 'rotateY', $a ),
+ new FunctionMatcher( 'rotateZ', $a ),
+ new FunctionMatcher( 'perspective', $l ),
+ ] ) )
+ ] );
+ $props['transform-origin'] = new Alternative( [
+ new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
+ new Juxtaposition( [
+ new Alternative( [ $center, $leftRight, $lp ] ),
+ new Alternative( [ $center, $topBottom, $lp ] ),
+ $olp
+ ] ),
+ UnorderedGroup::allOf( [
+ new Alternative( [ $center, $leftRight ] ),
+ new Juxtaposition( [ new Alternative( [ $center, $topBottom ] ), $olp ] ),
+ ] )
+ ] );
+ $props['transform-style'] = new KeywordMatcher( [ 'flat', 'preserve-3d' ] );
+ $props['perspective'] = new Alternative( [ new KeywordMatcher( 'none' ), $l ] );
+ $props['perspective-origin'] = new Alternative( [
+ new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
+ new Juxtaposition( [
+ new Alternative( [ $center, $leftRight, $lp ] ),
+ new Alternative( [ $center, $topBottom, $lp ] ),
+ ] ),
+ UnorderedGroup::allOf( [
+ new Alternative( [ $center, $leftRight ] ),
+ new Alternative( [ $center, $topBottom ] ),
+ ] )
+ ] );
+ $props['backface-visibility'] = new KeywordMatcher( [ 'visible', 'hidden' ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Text Module Level 3
+ * @see https://www.w3.org/TR/2013/WD-css-text-3-20131010/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssText3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['text-transform'] = new KeywordMatcher( [
+ 'none', 'capitalize', 'uppercase', 'lowercase', 'full-width'
+ ] );
+ $props['white-space'] = new KeywordMatcher( [
+ 'normal', 'pre', 'nowrap', 'pre-wrap', 'pre-line'
+ ] );
+ $props['tab-size'] = new Alternative( [ $matcherFactory->integer(), $matcherFactory->length() ] );
+ $props['line-break'] = new KeywordMatcher( [ 'auto', 'loose', 'normal', 'strict' ] );
+ $props['word-break'] = new KeywordMatcher( [ 'normal', 'keep-all', 'break-all' ] );
+ $props['hyphens'] = new KeywordMatcher( [ 'none', 'manual', 'auto' ] );
+ $props['word-wrap'] = new KeywordMatcher( [ 'normal', 'break-word' ] );
+ $props['overflow-wrap'] = $props['word-wrap'];
+ $props['text-align'] = new Alternative( [
+ new KeywordMatcher( [ 'start', 'end', 'left', 'right', 'center', 'justify', 'match-parent' ] ),
+ new Juxtaposition( [ new KeywordMatcher( 'start' ), new KeywordMatcher( 'end' ) ] ),
+ ] );
+ $props['text-align-last'] = new KeywordMatcher( [
+ 'auto', 'start', 'end', 'left', 'right', 'center', 'justify'
+ ] );
+ $props['text-justify'] = new KeywordMatcher( [ 'auto', 'none', 'inter-word', 'distribute' ] );
+ $props['word-spacing'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ $matcherFactory->lengthPercentage()
+ ] );
+ $props['letter-spacing'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ $matcherFactory->length()
+ ] );
+ $props['text-indent'] = UnorderedGroup::allOf( [
+ $matcherFactory->lengthPercentage(),
+ Quantifier::optional( new KeywordMatcher( 'hanging' ) ),
+ Quantifier::optional( new KeywordMatcher( 'each-line' ) ),
+ ] );
+ $props['hanging-punctuation'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'first' ),
+ new KeywordMatcher( [ 'force-end', 'allow-end' ] ),
+ new KeywordMatcher( 'last' ),
+ ] )
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS ext Decoration Module Level 3
+ * @see https://www.w3.org/TR/2013/CR-css-text-decor-3-20130801/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssTextDecor3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['text-decoration-line'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'underline' ),
+ new KeywordMatcher( 'overline' ),
+ new KeywordMatcher( 'line-through' ),
+ // new KeywordMatcher( 'blink' ), // NOOO!!!
+ ] )
+ ] );
+ $props['text-decoration-color'] = $matcherFactory->color();
+ $props['text-decoration-style'] = new KeywordMatcher( [
+ 'solid', 'double', 'dotted', 'dashed', 'wavy'
+ ] );
+ $props['text-decoration'] = UnorderedGroup::someOf( [
+ $props['text-decoration-line'],
+ $props['text-decoration-style'],
+ $props['text-decoration-color'],
+ ] );
+ $props['text-decoration-skip'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'objects' ),
+ new KeywordMatcher( 'spaces' ),
+ new KeywordMatcher( 'ink' ),
+ new KeywordMatcher( 'edges' ),
+ new KeywordMatcher( 'box-decoration' ),
+ ] )
+ ] );
+ $props['text-underline-position'] = new Alternative( [
+ new KeywordMatcher( 'auto' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'under' ),
+ new KeywordMatcher( [ 'left', 'right' ] ),
+ ] )
+ ] );
+ $props['text-emphasis-style'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'filled', 'open' ] ),
+ new KeywordMatcher( [ 'dot', 'circle', 'double-circle', 'triangle', 'sesame' ] )
+ ] ),
+ $matcherFactory->string(),
+ ] );
+ $props['text-emphasis-color'] = $matcherFactory->color();
+ $props['text-emphasis'] = UnorderedGroup::someOf( [
+ $props['text-emphasis-style'],
+ $props['text-emphasis-color'],
+ ] );
+ $props['text-emphasis-position'] = UnorderedGroup::allOf( [
+ new KeywordMatcher( [ 'over', 'under' ] ),
+ new KeywordMatcher( [ 'right', 'left' ] ),
+ ] );
+ $props['text-shadow'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ Quantifier::hash( UnorderedGroup::allOf( [
+ Quantifier::count( $matcherFactory->length(), 2, 3 ),
+ Quantifier::optional( $matcherFactory->color() ),
+ ] ) )
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Box Alignment Module Level 3
+ * @see https://www.w3.org/TR/2017/WD-css-align-3-20170215/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssAlign3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $normal = new KeywordMatcher( 'normal' );
+ $normalStretch = new KeywordMatcher( [ 'normal', 'stretch' ] );
+ $autoNormalStretch = new KeywordMatcher( [ 'auto', 'normal', 'stretch' ] );
+ $overflowPosition = Quantifier::optional( new KeywordMatcher( [ 'safe', 'unsafe' ] ) );
+ $selfPosition = new KeywordMatcher( [
+ 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right'
+ ] );
+ $overflowAndSelfPosition = UnorderedGroup::allOf( [ $overflowPosition, $selfPosition ] );
+ $baselinePosition = new Juxtaposition( [
+ Quantifier::optional( new KeywordMatcher( [ 'first', 'last' ] ) ),
+ new KeywordMatcher( 'baseline' )
+ ] );
+ $contentDistribution = new KeywordMatcher( [
+ 'space-between', 'space-around', 'space-evenly', 'stretch'
+ ] );
+ $contentPosition = new KeywordMatcher( [
+ 'center', 'start', 'end', 'flex-start', 'flex-end', 'left', 'right'
+ ] );
+
+ $props['align-content'] = new Alternative( [
+ $normal,
+ $baselinePosition,
+ UnorderedGroup::someOf( [
+ $contentDistribution,
+ UnorderedGroup::allOf( [ $overflowPosition, $contentPosition ] ),
+ ] )
+ ] );
+ $props['justify-content'] = $props['align-content'];
+ $props['place-content'] = Quantifier::count( new Alternative( [
+ $normal,
+ $baselinePosition,
+ $contentDistribution,
+ $contentPosition,
+ ] ), 1, 2 );
+ $props['align-self'] = new Alternative( [
+ $autoNormalStretch,
+ $baselinePosition,
+ $overflowAndSelfPosition,
+ ] );
+ $props['justify-self'] = $props['align-self'];
+ $props['place-self'] = Quantifier::count( new Alternative( [
+ $autoNormalStretch,
+ $baselinePosition,
+ $selfPosition,
+ ] ), 1, 2 );
+ $props['align-items'] = new Alternative( [
+ $normalStretch,
+ $baselinePosition,
+ $overflowAndSelfPosition,
+ ] );
+ $props['justify-items'] = new Alternative( [
+ $autoNormalStretch,
+ $baselinePosition,
+ $overflowAndSelfPosition,
+ UnorderedGroup::allOf( [
+ new KeywordMatcher( 'legacy' ),
+ new KeywordMatcher( [ 'left', 'right', 'center' ] ),
+ ] ),
+ ] );
+ $props['place-items'] = new Juxtaposition( [
+ new Alternative( [ $normalStretch, $baselinePosition, $selfPosition ] ),
+ Quantifier::optional( new Alternative( [
+ $autoNormalStretch, $baselinePosition, $selfPosition
+ ] ) ),
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Fragmentation Module Level 3
+ * @see https://www.w3.org/TR/2017/CR-css-break-3-20170209/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssBreak3( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $props['break-before'] = new KeywordMatcher( [
+ 'auto', 'avoid', 'avoid-page', 'page', 'left', 'right', 'recto', 'verso', 'avoid-column',
+ 'column', 'avoid-region', 'region'
+ ] );
+ $props['break-after'] = $props['break-before'];
+ $props['break-inside'] = new KeywordMatcher( [
+ 'auto', 'avoid', 'avoid-page', 'avoid-column', 'avoid-region'
+ ] );
+ $props['orphans'] = $matcherFactory->integer();
+ $props['widows'] = $matcherFactory->integer();
+ $props['box-decoration-break'] = new KeywordMatcher( [ 'slice', 'clone' ] );
+ $props['page-break-before'] = new KeywordMatcher( [
+ 'auto', 'always', 'avoid', 'left', 'right'
+ ] );
+ $props['page-break-after'] = $props['page-break-before'];
+ $props['page-break-inside'] = new KeywordMatcher( [ 'auto', 'avoid' ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Speech Module
+ * @see https://www.w3.org/TR/2012/CR-css3-speech-20120320/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssSpeech( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $decibel = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return !strcasecmp( $t->unit(), 'dB' );
+ } );
+
+ $props['voice-volume'] = new Alternative( [
+ new KeywordMatcher( 'silent' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'x-soft', 'soft', 'medium', 'loud', 'x-loud' ] ),
+ $decibel
+ ] ),
+ ] );
+ $props['voice-balance'] = new Alternative( [
+ $matcherFactory->number(),
+ new KeywordMatcher( [ 'left', 'center', 'right', 'leftwards', 'rightwards' ] ),
+ ] );
+ $props['speak'] = new KeywordMatcher( [ 'auto', 'none', 'normal' ] );
+ $props['speak-as'] = new Alternative( [
+ new KeywordMatcher( 'normal' ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( 'spell-out' ),
+ new KeywordMatcher( 'digits' ),
+ new KeywordMatcher( [ 'literal-punctuation', 'no-punctuation' ] ),
+ ] )
+ ] );
+ $props['pause-before'] = new Alternative( [
+ $matcherFactory->time(),
+ new KeywordMatcher( [ 'none', 'x-weak', 'weak', 'medium', 'strong', 'x-strong' ] ),
+ ] );
+ $props['pause-after'] = $props['pause-before'];
+ $props['pause'] = new Juxtaposition( [
+ $props['pause-before'],
+ Quantifier::optional( $props['pause-after'] )
+ ] );
+ $props['rest-before'] = $props['pause-before'];
+ $props['rest-after'] = $props['pause-after'];
+ $props['rest'] = $props['pause'];
+ $props['cue-before'] = new Alternative( [
+ new Juxtaposition( [ $matcherFactory->url( 'audio' ), Quantifier::optional( $decibel ) ] ),
+ new KeywordMatcher( 'none' )
+ ] );
+ $props['cue-after'] = $props['cue-before'];
+ $props['cue'] = new Juxtaposition( [
+ $props['cue-before'],
+ Quantifier::optional( $props['cue-after'] )
+ ] );
+ $props['voice-family'] = new Alternative( [
+ Quantifier::hash( new Alternative( [
+ new Alternative( [ //
+ $matcherFactory->string(),
+ Quantifier::plus( $matcherFactory->ident() ),
+ ] ),
+ new Juxtaposition( [ //
+ Quantifier::optional( new KeywordMatcher( [ 'child', 'young', 'old' ] ) ),
+ new KeywordMatcher( [ 'male', 'female', 'neutral' ] ),
+ Quantifier::optional( $matcherFactory->integer() ),
+ ] ),
+ ] ) ),
+ new KeywordMatcher( 'preserve' )
+ ] );
+ $props['voice-rate'] = UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'normal', 'x-slow', 'slow', 'medium', 'fast', 'x-fast' ] ),
+ $matcherFactory->percentage()
+ ] );
+ $props['voice-pitch'] = new Alternative( [
+ UnorderedGroup::allOf( [
+ new KeywordMatcher( 'absolute' ),
+ $matcherFactory->frequency(),
+ ] ),
+ UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'x-low', 'low', 'medium', 'high', 'x-high' ] ),
+ new Alternative( [
+ $matcherFactory->frequency(),
+ new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return !strcasecmp( $t->unit(), 'st' );
+ } ),
+ $matcherFactory->percentage()
+ ] ),
+ ] ),
+ ] );
+ $props['voice-range'] = $props['voice-pitch'];
+ $props['voice-stress'] = new KeywordMatcher( [
+ 'normal', 'strong', 'moderate', 'none', 'reduced'
+ ] );
+ $props['voice-duration'] = new Alternative( [
+ new KeywordMatcher( 'auto' ),
+ $matcherFactory->time()
+ ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Grid Layout Module Level 1
+ * @see https://www.w3.org/TR/2017/CR-css-grid-1-20170209/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssGrid1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+ $comma = $matcherFactory->comma();
+ $slash = new DelimMatcher( '/' );
+ $lineNamesO = Quantifier::optional( new BlockMatcher(
+ Token::T_LEFT_BRACKET, Quantifier::star( $matcherFactory->ident() )
+ ) );
+ $trackBreadth = new Alternative( [
+ $matcherFactory->lengthPercentage(),
+ new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+ return $t->value() >= 0 && !strcasecmp( $t->unit(), 'fr' );
+ } ),
+ new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
+ ] );
+ $inflexibleBreadth = new Alternative( [
+ $matcherFactory->lengthPercentage(),
+ new KeywordMatcher( [ 'min-content', 'max-content', 'auto' ] )
+ ] );
+ $fixedBreadth = $matcherFactory->lengthPercentage();
+ $trackSize = new Alternative( [
+ $trackBreadth,
+ new FunctionMatcher( 'minmax',
+ new Juxtaposition( [ $inflexibleBreadth, $trackBreadth ], true )
+ ),
+ new FunctionMatcher( 'fit-content', $matcherFactory->lengthPercentage() )
+ ] );
+ $fixedSize = new Alternative( [
+ $fixedBreadth,
+ new FunctionMatcher( 'minmax', new Juxtaposition( [ $fixedBreadth, $trackBreadth ], true ) ),
+ new FunctionMatcher( 'minmax',
+ new Juxtaposition( [ $inflexibleBreadth, $fixedBreadth ], true )
+ ),
+ ] );
+ $trackRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
+ $matcherFactory->integer(),
+ $comma,
+ Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
+ $lineNamesO
+ ] ) );
+ $autoRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
+ new KeywordMatcher( [ 'auto-fill', 'auto-fit' ] ),
+ $comma,
+ Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
+ $lineNamesO
+ ] ) );
+ $fixedRepeat = new FunctionMatcher( 'repeat', new Juxtaposition( [
+ $matcherFactory->integer(),
+ $comma,
+ Quantifier::plus( new Juxtaposition( [ $lineNamesO, $fixedSize ] ) ),
+ $lineNamesO
+ ] ) );
+ $trackList = new Juxtaposition( [
+ Quantifier::plus( new Juxtaposition( [
+ $lineNamesO, new Alternative( [ $trackSize, $trackRepeat ] )
+ ] ) ),
+ $lineNamesO
+ ] );
+ $autoTrackList = new Juxtaposition( [
+ Quantifier::star( new Juxtaposition( [
+ $lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
+ ] ) ),
+ $lineNamesO,
+ $autoRepeat,
+ Quantifier::star( new Juxtaposition( [
+ $lineNamesO, new Alternative( [ $fixedSize, $fixedRepeat ] )
+ ] ) ),
+ $lineNamesO,
+ ] );
+ $explicitTrackList = new Juxtaposition( [
+ Quantifier::plus( new Juxtaposition( [ $lineNamesO, $trackSize ] ) ),
+ $lineNamesO
+ ] );
+ $autoDense = UnorderedGroup::allOf( [
+ new KeywordMatcher( 'auto-flow' ),
+ Quantifier::optional( new KeywordMatcher( 'dense' ) )
+ ] );
+
+ $props['grid-template-columns'] = new Alternative( [
+ new KeywordMatcher( 'none' ), $trackList, $autoTrackList
+ ] );
+ $props['grid-template-rows'] = $props['grid-template-columns'];
+ $props['grid-template-areas'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ Quantifier::plus( $matcherFactory->string() ),
+ ] );
+ $props['grid-template'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ new Juxtaposition( [ $props['grid-template-rows'], $slash, $props['grid-template-columns'] ] ),
+ new Juxtaposition( [
+ Quantifier::plus( new Juxtaposition( [
+ $lineNamesO, $matcherFactory->string(), Quantifier::optional( $trackSize ), $lineNamesO
+ ] ) ),
+ Quantifier::optional( new Juxtaposition( [ $slash, $explicitTrackList ] ) ),
+ ] )
+ ] );
+ $props['grid-auto-columns'] = Quantifier::plus( $trackSize );
+ $props['grid-auto-rows'] = $props['grid-auto-columns'];
+ $props['grid-auto-flow'] = UnorderedGroup::someOf( [
+ new KeywordMatcher( [ 'row', 'column' ] ),
+ new KeywordMatcher( 'dense' )
+ ] );
+ $props['grid'] = new Alternative( [
+ $props['grid-template'],
+ new Juxtaposition( [
+ $props['grid-template-rows'],
+ $slash,
+ $autoDense,
+ Quantifier::optional( $props['grid-auto-columns'] ),
+ ] ),
+ new Juxtaposition( [
+ $autoDense,
+ Quantifier::optional( $props['grid-auto-rows'] ),
+ $slash,
+ $props['grid-template-columns'],
+ ] )
+ ] );
+
+ $gridLine = new Alternative( [
+ new KeywordMatcher( 'auto' ),
+ $matcherFactory->ident(),
+ UnorderedGroup::allOf( [
+ $matcherFactory->integer(),
+ Quantifier::optional( $matcherFactory->ident() )
+ ] ),
+ UnorderedGroup::allOf( [
+ new KeywordMatcher( 'span' ),
+ UnorderedGroup::someOf( [
+ $matcherFactory->integer(),
+ $matcherFactory->ident(),
+ ] )
+ ] )
+ ] );
+ $props['grid-row-start'] = $gridLine;
+ $props['grid-column-start'] = $gridLine;
+ $props['grid-row-end'] = $gridLine;
+ $props['grid-column-end'] = $gridLine;
+ $props['grid-row'] = new Juxtaposition( [
+ $gridLine, Quantifier::optional( new Juxtaposition( [ $slash, $gridLine ] ) )
+ ] );
+ $props['grid-column'] = $props['grid-row'];
+ $props['grid-area'] = new Juxtaposition( [
+ $gridLine, Quantifier::count( new Juxtaposition( [ $slash, $gridLine ] ), 0, 3 )
+ ] );
+
+ $props['grid-row-gap'] = $matcherFactory->lengthPercentage();
+ $props['grid-column-gap'] = $matcherFactory->lengthPercentage();
+ $props['grid-gap'] = new Juxtaposition( [
+ $props['grid-row-gap'], Quantifier::optional( $props['grid-column-gap'] )
+ ] );
+
+ // Grid uses Flexbox's order property too. Copying is ok as long as
+ // it's the identical object.
+ $props['order'] = $this->cssFlexbox3( $matcherFactory )['order'];
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Filter Effects Module Level 1
+ * @see https://www.w3.org/TR/2014/WD-filter-effects-1-20141125/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssFilter1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $props = [];
+
+ $props['filter'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ Quantifier::plus( new Alternative( [
+ new FunctionMatcher( 'blur', $matcherFactory->length() ),
+ new FunctionMatcher( 'brightness', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'contrast', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'drop-shadow', new Juxtaposition( [
+ Quantifier::count( $matcherFactory->length(), 2, 3 ),
+ Quantifier::optional( $matcherFactory->color() ),
+ ] ) ),
+ new FunctionMatcher( 'grayscale', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'hue-rotate', $matcherFactory->angle() ),
+ new FunctionMatcher( 'invert', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'opacity', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'saturate', $matcherFactory->numberPercentage() ),
+ new FunctionMatcher( 'sepia', $matcherFactory->numberPercentage() ),
+ $matcherFactory->url( 'svg' ),
+ ] ) )
+ ] );
+ $props['flood-color'] = $matcherFactory->color();
+ $props['flood-opacity'] = $matcherFactory->numberPercentage();
+ $props['color-interpolation-filters'] = new KeywordMatcher( [ 'auto', 'sRGB', 'linearRGB' ] );
+ $props['lighting-color'] = $matcherFactory->color();
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Shapes and masking share these basic shapes
+ * @see https://www.w3.org/TR/2014/CR-css-shapes-1-20140320/#basic-shape-functions
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher
+ */
+ protected function basicShapes( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $border = $this->cssBorderBackground3( $matcherFactory );
+ $sa = $matcherFactory->lengthPercentage();
+ $sr = new Alternative( [
+ $sa,
+ new KeywordMatcher( [ 'closest-side', 'farthest-side' ] ),
+ ] );
+
+ $basicShape = new Alternative( [
+ new FunctionMatcher( 'inset', new Juxtaposition( [
+ Quantifier::count( $sa, 1, 4 ),
+ Quantifier::optional( new Juxtaposition( [
+ new KeywordMatcher( 'round' ), $border['border-radius']
+ ] ) )
+ ] ) ),
+ new FunctionMatcher( 'circle', new Juxtaposition( [
+ Quantifier::optional( $sr ),
+ Quantifier::optional( new Juxtaposition( [
+ new KeywordMatcher( 'at' ), $matcherFactory->position()
+ ] ) )
+ ] ) ),
+ new FunctionMatcher( 'ellipse', new Juxtaposition( [
+ Quantifier::optional( Quantifier::count( $sr, 2, 2 ) ),
+ Quantifier::optional( new Juxtaposition( [
+ new KeywordMatcher( 'at' ), $matcherFactory->position()
+ ] ) )
+ ] ) ),
+ new FunctionMatcher( 'polygon', new Juxtaposition( [
+ Quantifier::optional( new KeywordMatcher( [ 'nonzero', 'evenodd' ] ) ),
+ Quantifier::hash( Quantifier::count( $sa, 2, 2 ) ),
+ ], true ) ),
+ ] );
+
+ $this->cache[__METHOD__] = $basicShape;
+ return $basicShape;
+ }
+
+ /**
+ * Properties for CSS Shapes Module Level 1
+ * @see https://www.w3.org/TR/2014/CR-css-shapes-1-20140320/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssShapes1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $shapeBoxKW = $this->backgroundTypes( $matcherFactory )['boxKeywords'];
+ $shapeBoxKW[] = 'margin-box';
+
+ $props = [];
+
+ $props['shape-outside'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ UnorderedGroup::someOf( [
+ $this->basicShapes( $matcherFactory ),
+ new KeywordMatcher( $shapeBoxKW ),
+ ] ),
+ $matcherFactory->url( 'image' ),
+ ] );
+ $props['shape-image-threshold'] = $matcherFactory->number();
+ $props['shape-margin'] = $matcherFactory->lengthPercentage();
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+
+ /**
+ * Properties for CSS Masking Module Level 1
+ * @see https://www.w3.org/TR/2014/CR-css-masking-1-20140826/
+ * @param MatcherFactory $matcherFactory Factory for Matchers
+ * @return Matcher[] Array mapping declaration names (lowercase) to Matchers for the values
+ */
+ protected function cssMasking1( MatcherFactory $matcherFactory ) {
+ // @codeCoverageIgnoreStart
+ if ( isset( $this->cache[__METHOD__] ) ) {
+ return $this->cache[__METHOD__];
+ }
+ // @codeCoverageIgnoreEnd
+
+ $slash = new DelimMatcher( '/' );
+ $bgtypes = $this->backgroundTypes( $matcherFactory );
+ $bg = $this->cssBorderBackground3( $matcherFactory );
+ $geometryBoxKeywords = array_merge( $bgtypes['boxKeywords'], [
+ 'margin-box', 'fill-box', 'stroke-box', 'view-box'
+ ] );
+ $geometryBox = new KeywordMatcher( $geometryBoxKeywords );
+ $maskRef = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ $matcherFactory->image(),
+ $matcherFactory->url( 'svg' ),
+ ] );
+ $maskMode = new KeywordMatcher( [ 'alpha', 'luminance', 'auto' ] );
+ $maskClip = new KeywordMatcher( array_merge( $geometryBoxKeywords, [ 'no-clip' ] ) );
+ $maskComposite = new KeywordMatcher( [ 'add', 'subtract', 'intersect', 'exclude' ] );
+
+ $props = [];
+
+ $props['clip-path'] = new Alternative( [
+ $matcherFactory->url( 'svg' ),
+ UnorderedGroup::someOf( [
+ $this->basicShapes( $matcherFactory ),
+ $geometryBox,
+ ] ),
+ new KeywordMatcher( 'none' ),
+ ] );
+ $props['clip-rule'] = new KeywordMatcher( [ 'nonzero', 'evenodd' ] );
+ $props['mask-image'] = Quantifier::hash( $maskRef );
+ $props['mask-mode'] = Quantifier::hash( $maskMode );
+ $props['mask-repeat'] = $bg['background-repeat'];
+ $props['mask-position'] = Quantifier::hash( $matcherFactory->position() );
+ $props['mask-clip'] = Quantifier::hash( $maskClip );
+ $props['mask-origin'] = Quantifier::hash( $geometryBox );
+ $props['mask-size'] = $bg['background-size'];
+ $props['mask-composite'] = Quantifier::hash( $maskComposite );
+ $props['mask'] = Quantifier::hash( UnorderedGroup::someOf( [
+ new Juxtaposition( [ $maskRef, Quantifier::optional( $maskMode ) ] ),
+ new Juxtaposition( [
+ $matcherFactory->position(),
+ Quantifier::optional( new Juxtaposition( [ $slash, $bgtypes['bgsize'] ] ) ),
+ ] ),
+ $bgtypes['bgrepeat'],
+ $geometryBox,
+ $maskClip,
+ $maskComposite,
+ ] ) );
+ $props['mask-border-source'] = new Alternative( [
+ new KeywordMatcher( 'none' ),
+ $matcherFactory->image(),
+ ] );
+ $props['mask-border-mode'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
+ $props['mask-border-slice'] = new Juxtaposition( [ // Different from border-image-slice, sigh
+ Quantifier::count( $matcherFactory->numberPercentage(), 1, 4 ),
+ Quantifier::optional( new KeywordMatcher( 'fill' ) ),
+ ] );
+ $props['mask-border-width'] = $bg['border-image-width'];
+ $props['mask-border-outset'] = $bg['border-image-outset'];
+ $props['mask-border-repeat'] = $bg['border-image-repeat'];
+ $props['mask-border'] = UnorderedGroup::someOf( [
+ $props['mask-border-source'],
+ new Juxtaposition( [
+ $props['mask-border-slice'],
+ Quantifier::optional( new Juxtaposition( [
+ $slash,
+ Quantifier::optional( $props['mask-border-width'] ),
+ Quantifier::optional( new Juxtaposition( [
+ $slash,
+ $props['mask-border-outset'],
+ ] ) ),
+ ] ) ),
+ ] ),
+ $props['mask-border-repeat'],
+ $props['mask-border-mode'],
+ ] );
+ $props['mask-type'] = new KeywordMatcher( [ 'luminance', 'alpha' ] );
+
+ $this->cache[__METHOD__] = $props;
+ return $props;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleRuleSanitizer.php
new file mode 100644
index 000000000..c8f6c426a
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleRuleSanitizer.php
@@ -0,0 +1,119 @@
+ [],
+ ];
+
+ // Add optional whitespace around the selector-matcher, because
+ // selector-matchers don't usually have it.
+ if ( !$selectorMatcher->getDefaultOptions()['skip-whitespace'] ) {
+ $ows = MatcherFactory::singleton()->optionalWhitespace();
+ $this->selectorMatcher = new Juxtaposition( [
+ $ows,
+ $selectorMatcher,
+ $ows->capture( 'trailingWS' ),
+ ] );
+ $this->selectorMatcher->setDefaultOptions( $selectorMatcher->getDefaultOptions() );
+ } else {
+ $this->selectorMatcher = $selectorMatcher;
+ }
+
+ $this->propertySanitizer = $propertySanitizer;
+ $this->prependSelectors = $options['prependSelectors'];
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof QualifiedRule;
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof QualifiedRule ) {
+ $this->sanitizationError( 'expected-qualified-rule', $object );
+ return null;
+ }
+
+ // Test that the prelude is a valid selector list
+ $match = $this->selectorMatcher->match( $object->getPrelude(), [ 'mark-significance' => true ] );
+ if ( !$match ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-selector-list', $cv );
+ } else {
+ $this->sanitizationError( 'missing-selector-list', $object );
+ }
+ return null;
+ }
+
+ $ret = clone( $object );
+
+ // If necessary, munge the selector list
+ if ( $this->prependSelectors ) {
+ $prelude = $ret->getPrelude();
+ $comma = [
+ new Token( Token::T_COMMA ),
+ new Token( Token::T_WHITESPACE, [ 'significant' => false ] )
+ ];
+ $oldPrelude = $object->getPrelude();
+ $prelude->clear();
+ foreach ( $match->getCapturedMatches() as $m ) {
+ if ( $m->getName() === 'selector' ) {
+ if ( $prelude->count() ) {
+ $prelude->add( $comma );
+ }
+ $prelude->add( $this->prependSelectors );
+ $prelude->add( $m->getValues() );
+ } elseif ( $m->getName() === 'trailingWS' && $m->getLength() > 0 ) {
+ $prelude->add( $m->getValues() );
+ }
+ }
+ }
+
+ $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylesheetSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylesheetSanitizer.php
new file mode 100644
index 000000000..7e6bdf902
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylesheetSanitizer.php
@@ -0,0 +1,114 @@
+setRuleSanitizers( $ruleSanitizers );
+ }
+
+ /**
+ * Create and return a default StylesheetSanitizer.
+ * @note This method exists more to be an example of how to put everything
+ * together than to be used directly.
+ * @return StylesheetSanitizer
+ */
+ public static function newDefault() {
+ // First, we need a matcher factory for the stuff all the sanitizers
+ // will need.
+ $matcherFactory = MatcherFactory::singleton();
+
+ // This is the sanitizer for a single "property: value", that gets used by
+ // StyleRuleSanitizer and various others.
+ $propertySanitizer = new StylePropertySanitizer( $matcherFactory );
+
+ // These are sanitizers for different types of rules that can appear in
+ // stylesheets and can be nested inside @media and @supports blocks.
+ // The keys in the array aren't used for anything by the library, but
+ // may help humans reading it.
+ $ruleSanitizers = [
+ 'style' => new StyleRuleSanitizer( $matcherFactory->cssSelectorList(), $propertySanitizer ),
+ '@font-face' => new FontFaceAtRuleSanitizer( $matcherFactory ),
+ '@font-feature-values' => new FontFeatureValuesAtRuleSanitizer( $matcherFactory ),
+ '@keyframes' => new KeyframesAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
+ '@page' => new PageAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
+ '@media' => new MediaAtRuleSanitizer( $matcherFactory->cssMediaQueryList() ),
+ '@supports' => new SupportsAtRuleSanitizer( $matcherFactory, [
+ 'declarationSanitizer' => $propertySanitizer,
+ ] ),
+ ];
+
+ // Inject the above list into the @media and @supports sanitizers.
+ $ruleSanitizers['@media']->setRuleSanitizers( $ruleSanitizers );
+ $ruleSanitizers['@supports']->setRuleSanitizers( $ruleSanitizers );
+
+ // Now we can put together the StylesheetSanitizer
+ $sanitizer = new StylesheetSanitizer( $ruleSanitizers + [
+ // Note there's intentionally no "@charset" sanitizer, as that at-rule
+ // was removed in the Editor's Draft in favor of special handling
+ // in the parser.
+ '@import' => new ImportAtRuleSanitizer( $matcherFactory ),
+ '@namespace' => new NamespaceAtRuleSanitizer( $matcherFactory ),
+ ] );
+
+ return $sanitizer;
+ }
+
+ /**
+ * Access the list of rule sanitizers
+ * @return RuleSanitizer[]
+ */
+ public function getRuleSanitizers() {
+ return $this->ruleSanitizers;
+ }
+
+ /**
+ * Set the list of rule sanitizers
+ * @param RuleSanitizer[] $ruleSanitizers
+ */
+ public function setRuleSanitizers( array $ruleSanitizers ) {
+ Util::assertAllInstanceOf( $ruleSanitizers, RuleSanitizer::class, '$ruleSanitizers' );
+ $this->ruleSanitizers = $ruleSanitizers;
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ $isSheet = $object instanceof Stylesheet;
+ if ( $isSheet ) {
+ $object = $object->getRuleList();
+ }
+ if ( !$object instanceof RuleList ) {
+ $this->sanitizationError( 'expected-stylesheet', $object );
+ return null;
+ }
+
+ $ret = $this->sanitizeRules( $this->ruleSanitizers, $object );
+ if ( $isSheet ) {
+ $ret = new Stylesheet( $ret );
+ }
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/SupportsAtRuleSanitizer.php b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/SupportsAtRuleSanitizer.php
new file mode 100644
index 000000000..77ff44ab8
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Sanitizer/SupportsAtRuleSanitizer.php
@@ -0,0 +1,165 @@
+ true,
+ ];
+ $declarationSanitizer = null;
+ if ( isset( $options['declarationSanitizer'] ) ) {
+ $declarationSanitizer = $options['declarationSanitizer'];
+ if ( !$declarationSanitizer instanceof PropertySanitizer ) {
+ throw new \InvalidArgumentException(
+ 'declarationSanitizer must be an instance of ' . PropertySanitizer::class
+ );
+ }
+ }
+
+ $ws = $matcherFactory->significantWhitespace();
+ $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
+
+ if ( $options['strict'] ) {
+ $generalEnclosed = new NothingMatcher();
+ } else {
+ $generalEnclosed = new Alternative( [
+ new FunctionMatcher( null, $anythingPlus ),
+ new BlockMatcher( Token::T_LEFT_PAREN, new Juxtaposition( [
+ $matcherFactory->ident(), $anythingPlus
+ ] ) ),
+ ] );
+ }
+
+ $supportsConditionBlock = new NothingMatcher(); // temp
+ $supportsConditionInParens = new Alternative( [
+ &$supportsConditionBlock,
+ new BlockMatcher( Token::T_LEFT_PAREN, new CheckedMatcher(
+ $anythingPlus,
+ function ( ComponentValueList $list, Match $match, array $options )
+ use ( $declarationSanitizer )
+ {
+ $cvlist = new ComponentValueList( $match->getValues() );
+ $parser = Parser::newFromTokens( $cvlist->toTokenArray() );
+ $declaration = $parser->parseDeclaration();
+ if ( $parser->getParseErrors() || !$declaration ) {
+ return false;
+ }
+ if ( !$declarationSanitizer ) {
+ return true;
+ }
+ $oldErrors = $declarationSanitizer->sanitizationErrors;
+ $ret = $declarationSanitizer->doSanitize( $declaration );
+ $errors = $declarationSanitizer->getSanitizationErrors();
+ $declarationSanitizer->sanitizationErrors = $oldErrors;
+ return $ret === $declaration && !$errors;
+ }
+ ) ),
+ $generalEnclosed,
+ ] );
+ $supportsCondition = new Alternative( [
+ new Juxtaposition( [ new KeywordMatcher( 'not' ), $ws, $supportsConditionInParens ] ),
+ new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
+ $ws, new KeywordMatcher( 'and' ), $ws, $supportsConditionInParens
+ ] ) ) ] ),
+ new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
+ $ws, new KeywordMatcher( 'or' ), $ws, $supportsConditionInParens
+ ] ) ) ] ),
+ $supportsConditionInParens,
+ ] );
+ $supportsConditionBlock = new BlockMatcher( Token::T_LEFT_PAREN, $supportsCondition );
+
+ $this->conditionMatcher = $supportsCondition;
+ }
+
+ /**
+ * Access the list of rule sanitizers
+ * @return RuleSanitizer[]
+ */
+ public function getRuleSanitizers() {
+ return $this->ruleSanitizers;
+ }
+
+ /**
+ * Set the list of rule sanitizers
+ * @param RuleSanitizer[] $ruleSanitizers
+ */
+ public function setRuleSanitizers( array $ruleSanitizers ) {
+ Util::assertAllInstanceOf( $ruleSanitizers, RuleSanitizer::class, '$ruleSanitizers' );
+ $this->ruleSanitizers = $ruleSanitizers;
+ }
+
+ public function handlesRule( Rule $rule ) {
+ return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'supports' );
+ }
+
+ protected function doSanitize( CSSObject $object ) {
+ if ( !$object instanceof Rule || !$this->handlesRule( $object ) ) {
+ $this->sanitizationError( 'expected-at-rule', $object, [ 'supports' ] );
+ return null;
+ }
+
+ if ( $object->getBlock() === null ) {
+ $this->sanitizationError( 'at-rule-block-required', $object, [ 'supports' ] );
+ return null;
+ }
+
+ // Test the media query
+ if ( !$this->conditionMatcher->match( $object->getPrelude(), [ 'mark-significance' => true ] ) ) {
+ $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
+ if ( $cv ) {
+ $this->sanitizationError( 'invalid-supports-condition', $cv );
+ } else {
+ $this->sanitizationError( 'missing-supports-condition', $object );
+ }
+ return null;
+ }
+
+ $ret = clone( $object );
+ $this->fixPreludeWhitespace( $ret, false );
+ $this->sanitizeRuleBlock( $ret->getBlock(), $this->ruleSanitizers );
+
+ return $ret;
+ }
+}
diff --git a/lib/css-sanitizer/Wikimedia/CSS/Util.php b/lib/css-sanitizer/Wikimedia/CSS/Util.php
new file mode 100644
index 000000000..6247975a8
--- /dev/null
+++ b/lib/css-sanitizer/Wikimedia/CSS/Util.php
@@ -0,0 +1,124 @@
+ $v ) {
+ if ( !$v instanceof $class ) {
+ $vtype = is_object( $v ) ? get_class( $v ) : gettype( $v );
+ throw new \InvalidArgumentException(
+ "$what may only contain instances of $class" .
+ " (found $vtype at index $k)"
+ );
+ }
+ }
+ }
+
+ /**
+ * Check that a set of tokens are all of the same type
+ * @param Token[] $array
+ * @param string $type
+ * @param string $what Describe the array being checked
+ * @throws \InvalidArgumentException
+ */
+ public static function assertAllTokensOfType( array $array, $type, $what ) {
+ foreach ( $array as $k => $v ) {
+ if ( !$v instanceof Token ) {
+ $vtype = is_object( $v ) ? get_class( $v ) : gettype( $v );
+ throw new \InvalidArgumentException(
+ "$what may only contain instances of " . Token::class .
+ " (found $vtype at index $k)"
+ );
+ }
+ if ( $v->type() !== $type ) {
+ throw new \InvalidArgumentException(
+ "$what may only contain \"$type\" tokens" .
+ " (found \"{$v->type()}\" at index $k)"
+ );
+ }
+ }
+ }
+
+ /**
+ * Find the first non-whitespace ComponentValue in a list
+ * @param TokenList|ComponentValueList $list
+ * @return ComponentValue|null
+ */
+ public static function findFirstNonWhitespace( $list ) {
+ if ( !$list instanceof TokenList && !$list instanceof ComponentValueList ) {
+ throw new \InvalidArgumentException( 'List must be TokenList or ComponentValueList' );
+ }
+ foreach ( $list as $v ) {
+ if ( !$v instanceof Token || $v->type() !== Token::T_WHITESPACE ) {
+ return $v;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Turn a CSSObject into a string
+ * @param CSSObject $object
+ * @param array $options Serialziation options:
+ * - minify: (bool) Skip comments and insignificant tokens
+ * @return string
+ */
+ public static function stringify( CSSObject $object, $options = [] ) {
+ $tokens = $object->toTokenArray();
+ if ( !$tokens ) {
+ return '';
+ }
+
+ if ( !empty( $options['minify'] ) ) {
+ // Last second check for significant whitespace
+ $e = count( $tokens ) - 1;
+ for ( $i = 1; $i < $e; $i++ ) {
+ $t = $tokens[$i];
+ if ( $t->type() === Token::T_WHITESPACE && !$t->significant() &&
+ Token::separate( $tokens[$i-1], $tokens[$i+1] )
+ ) {
+ $tokens[$i] = $t->copyWithSignificance( true );
+ }
+ }
+
+ // Filter!
+ $tokens = array_filter( $tokens, function ( $t ) {
+ return $t->significant();
+ } );
+ }
+
+ $prev = reset( $tokens );
+ $ret = (string)$prev;
+ while ( ( $token = next( $tokens ) ) !== false ) {
+ if ( Token::separate( $prev, $token ) ) {
+ // Per https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#serialization
+ $ret .= '/**/';
+ }
+ $ret .= (string)$token;
+ $prev = $token;
+ }
+ return $ret;
+ }
+}