From a6e7db9b89b94d6967a33e75302c7635f07c4763 Mon Sep 17 00:00:00 2001 From: Sacha Reinert Date: Wed, 14 Jun 2017 06:26:15 +0200 Subject: [PATCH] Use Wikimedia's css sanitizer for custom css parsing (#919) --- customcss.php | 36 +- .../Wikimedia/CSS/Grammar/Alternative.php | 41 + .../Wikimedia/CSS/Grammar/AnythingMatcher.php | 137 ++ .../Wikimedia/CSS/Grammar/BlockMatcher.php | 62 + .../Wikimedia/CSS/Grammar/CheckedMatcher.php | 38 + .../Wikimedia/CSS/Grammar/DelimMatcher.php | 50 + .../Wikimedia/CSS/Grammar/FunctionMatcher.php | 68 + .../Wikimedia/CSS/Grammar/Juxtaposition.php | 117 ++ .../Wikimedia/CSS/Grammar/KeywordMatcher.php | 50 + .../Wikimedia/CSS/Grammar/Match.php | 146 ++ .../Wikimedia/CSS/Grammar/Matcher.php | 257 +++ .../Wikimedia/CSS/Grammar/MatcherFactory.php | 1282 ++++++++++++ .../Wikimedia/CSS/Grammar/NoWhitespace.php | 23 + .../Wikimedia/CSS/Grammar/NonEmpty.php | 33 + .../Wikimedia/CSS/Grammar/NothingMatcher.php | 19 + .../Wikimedia/CSS/Grammar/Quantifier.php | 176 ++ .../Wikimedia/CSS/Grammar/TokenMatcher.php | 41 + .../Wikimedia/CSS/Grammar/UnorderedGroup.php | 124 ++ .../Wikimedia/CSS/Grammar/UrlMatcher.php | 91 + .../CSS/Grammar/WhitespaceMatcher.php | 60 + .../Wikimedia/CSS/Objects/AtRule.php | 126 ++ .../Wikimedia/CSS/Objects/CSSFunction.php | 89 + .../Wikimedia/CSS/Objects/CSSObject.php | 39 + .../Wikimedia/CSS/Objects/CSSObjectList.php | 243 +++ .../Wikimedia/CSS/Objects/ComponentValue.php | 28 + .../CSS/Objects/ComponentValueList.php | 34 + .../Wikimedia/CSS/Objects/Declaration.php | 123 ++ .../Wikimedia/CSS/Objects/DeclarationList.php | 25 + .../CSS/Objects/DeclarationOrAtRule.php | 14 + .../CSS/Objects/DeclarationOrAtRuleList.php | 25 + .../Wikimedia/CSS/Objects/QualifiedRule.php | 87 + .../Wikimedia/CSS/Objects/Rule.php | 33 + .../Wikimedia/CSS/Objects/RuleList.php | 18 + .../Wikimedia/CSS/Objects/SimpleBlock.php | 125 ++ .../Wikimedia/CSS/Objects/Stylesheet.php | 59 + .../Wikimedia/CSS/Objects/Token.php | 568 ++++++ .../Wikimedia/CSS/Objects/TokenList.php | 33 + .../Wikimedia/CSS/Parser/DataSource.php | 29 + .../CSS/Parser/DataSourceTokenizer.php | 957 +++++++++ .../Wikimedia/CSS/Parser/Encoder.php | 330 +++ .../Wikimedia/CSS/Parser/Parser.php | 658 ++++++ .../Wikimedia/CSS/Parser/StringDataSource.php | 91 + .../CSS/Parser/TokenListTokenizer.php | 60 + .../Wikimedia/CSS/Parser/Tokenizer.php | 33 + .../CSS/Sanitizer/FontFaceAtRuleSanitizer.php | 170 ++ .../FontFeatureValueAtRuleSanitizer.php | 83 + .../FontFeatureValuesAtRuleSanitizer.php | 84 + .../CSS/Sanitizer/ImportAtRuleSanitizer.php | 72 + .../Sanitizer/KeyframesAtRuleSanitizer.php | 79 + .../CSS/Sanitizer/MarginAtRuleSanitizer.php | 65 + .../CSS/Sanitizer/MediaAtRuleSanitizer.php | 84 + .../Sanitizer/NamespaceAtRuleSanitizer.php | 72 + .../CSS/Sanitizer/PageAtRuleSanitizer.php | 133 ++ .../CSS/Sanitizer/PropertySanitizer.php | 129 ++ .../Wikimedia/CSS/Sanitizer/RuleSanitizer.php | 113 ++ .../Wikimedia/CSS/Sanitizer/Sanitizer.php | 140 ++ .../CSS/Sanitizer/StyleAttributeSanitizer.php | 70 + .../CSS/Sanitizer/StylePropertySanitizer.php | 1798 +++++++++++++++++ .../CSS/Sanitizer/StyleRuleSanitizer.php | 119 ++ .../CSS/Sanitizer/StylesheetSanitizer.php | 114 ++ .../CSS/Sanitizer/SupportsAtRuleSanitizer.php | 165 ++ lib/css-sanitizer/Wikimedia/CSS/Util.php | 124 ++ 62 files changed, 10273 insertions(+), 19 deletions(-) create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/Alternative.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/AnythingMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/BlockMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/CheckedMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/DelimMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/FunctionMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/Juxtaposition.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/KeywordMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/Match.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/Matcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/MatcherFactory.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/NoWhitespace.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/NonEmpty.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/NothingMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/Quantifier.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/TokenMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/UnorderedGroup.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/UrlMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Grammar/WhitespaceMatcher.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/AtRule.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/CSSFunction.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/CSSObject.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/CSSObjectList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValue.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/ComponentValueList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/Declaration.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationOrAtRule.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/DeclarationOrAtRuleList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/QualifiedRule.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/Rule.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/RuleList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/SimpleBlock.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/Stylesheet.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/Token.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Objects/TokenList.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/DataSource.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/DataSourceTokenizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/Encoder.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/Parser.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/StringDataSource.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/TokenListTokenizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Parser/Tokenizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFaceAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValueAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/FontFeatureValuesAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/ImportAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/KeyframesAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MarginAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/MediaAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/NamespaceAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PageAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/PropertySanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/RuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/Sanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleAttributeSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylePropertySanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StyleRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/StylesheetSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Sanitizer/SupportsAtRuleSanitizer.php create mode 100644 lib/css-sanitizer/Wikimedia/CSS/Util.php 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; + } +}