Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
78 / 78 |
|
100.00% |
3 / 3 |
CRAP | |
100.00% |
1 / 1 |
| StyleRuleSanitizer | |
100.00% |
78 / 78 |
|
100.00% |
3 / 3 |
18 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
5 | |||
| handlesRule | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| doSanitize | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
12 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @file |
| 4 | * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0 |
| 5 | */ |
| 6 | |
| 7 | namespace Wikimedia\CSS\Sanitizer; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use Wikimedia\CSS\Grammar\Juxtaposition; |
| 11 | use Wikimedia\CSS\Grammar\Matcher; |
| 12 | use Wikimedia\CSS\Grammar\MatcherFactory; |
| 13 | use Wikimedia\CSS\Grammar\Quantifier; |
| 14 | use Wikimedia\CSS\Objects\ComponentValue; |
| 15 | use Wikimedia\CSS\Objects\ComponentValueList; |
| 16 | use Wikimedia\CSS\Objects\CSSObject; |
| 17 | use Wikimedia\CSS\Objects\QualifiedRule; |
| 18 | use Wikimedia\CSS\Objects\Rule; |
| 19 | use Wikimedia\CSS\Objects\Token; |
| 20 | use Wikimedia\CSS\Util; |
| 21 | |
| 22 | /** |
| 23 | * Sanitizes a CSS style rule |
| 24 | * @see https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#style-rules |
| 25 | */ |
| 26 | class StyleRuleSanitizer extends RuleSanitizer { |
| 27 | |
| 28 | /** @var Matcher */ |
| 29 | protected $selectorMatcher; |
| 30 | |
| 31 | /** @var ComponentValue[] */ |
| 32 | protected $prependSelectors; |
| 33 | |
| 34 | /** @var Matcher|null */ |
| 35 | protected $hoistableMatcher; |
| 36 | |
| 37 | /** @var PropertySanitizer */ |
| 38 | protected $propertySanitizer; |
| 39 | |
| 40 | /** |
| 41 | * @param Matcher $selectorMatcher Matcher for valid selectors. |
| 42 | * Probably from MatcherFactory::cssSelectorList(). |
| 43 | * @param PropertySanitizer $propertySanitizer Sanitizer to test property declarations. |
| 44 | * Probably an instance of StylePropertySanitizer. |
| 45 | * @param array $options Additional options |
| 46 | * - prependSelectors: (ComponentValue[]) Prepend this (and a whitespace) to all selectors. |
| 47 | * Note: $selectorMatcher must capture each selector with the name 'selector'. |
| 48 | * - hoistableComponentMatcher: (Matcher) Component groups (simple selector sequences, |
| 49 | * in CSS3 Selectors terminology) matched by this will be hoisted before the prepended |
| 50 | * selector sequence. (To be more precise: the hoisted part is the longest prefix of |
| 51 | * the selector that only contains matching simple selector sequences and descendant |
| 52 | * combinators, and is not followed by a non-descendant combinator.) |
| 53 | * This can be used to allow filtering by top-level conditional classes/IDs emitted by |
| 54 | * some framework (e.g. html.no-js) while still jailing selectors into some subsection |
| 55 | * of the content. For example, if prependSelectors is equivalent to '#content' and |
| 56 | * hoistableComponentMatcher to [html|body]<simple selector>* will turn |
| 57 | * 'html.no-js body.ltr div.list' into 'html.no-js body.ltr #content div.list'. |
| 58 | * Note: $selectorMatcher must capture each simple selector group with the name 'simple' |
| 59 | * and the combinators with 'combinator'. |
| 60 | */ |
| 61 | public function __construct( |
| 62 | Matcher $selectorMatcher, PropertySanitizer $propertySanitizer, array $options = [] |
| 63 | ) { |
| 64 | $options += [ |
| 65 | 'prependSelectors' => [], |
| 66 | 'hoistableComponentMatcher' => null, |
| 67 | ]; |
| 68 | Util::assertAllInstanceOf( |
| 69 | $options['prependSelectors'], ComponentValue::class, 'prependSelectors' |
| 70 | ); |
| 71 | if ( $options['hoistableComponentMatcher'] !== null && |
| 72 | !$options['hoistableComponentMatcher'] instanceof Matcher |
| 73 | ) { |
| 74 | throw new InvalidArgumentException( 'hoistableComponentMatcher must be a Matcher' ); |
| 75 | } |
| 76 | |
| 77 | $matcherFactory = MatcherFactory::singleton(); |
| 78 | |
| 79 | // Add optional whitespace around the selector-matcher, because |
| 80 | // selector-matchers don't usually have it. |
| 81 | if ( !$selectorMatcher->getDefaultOptions()['skip-whitespace'] ) { |
| 82 | $ows = $matcherFactory->optionalWhitespace(); |
| 83 | $this->selectorMatcher = new Juxtaposition( [ |
| 84 | $ows, |
| 85 | $selectorMatcher, |
| 86 | $ows->capture( 'trailingWS' ), |
| 87 | ] ); |
| 88 | $this->selectorMatcher->setDefaultOptions( $selectorMatcher->getDefaultOptions() ); |
| 89 | } else { |
| 90 | $this->selectorMatcher = $selectorMatcher; |
| 91 | } |
| 92 | |
| 93 | $this->propertySanitizer = $propertySanitizer; |
| 94 | $this->prependSelectors = $options['prependSelectors']; |
| 95 | if ( $options['hoistableComponentMatcher'] ) { |
| 96 | $hoistablePrefixMatcher = new Juxtaposition( [ |
| 97 | $options['hoistableComponentMatcher'], |
| 98 | Quantifier::star( new Juxtaposition( [ |
| 99 | $matcherFactory->significantWhitespace(), |
| 100 | $options['hoistableComponentMatcher'], |
| 101 | ] ) ) |
| 102 | ] ); |
| 103 | $this->hoistableMatcher = new Juxtaposition( [ |
| 104 | $hoistablePrefixMatcher->capture( 'prefix' ), |
| 105 | $matcherFactory->significantWhitespace()->capture( 'ws' ), |
| 106 | $matcherFactory->cssSelector()->capture( 'postfix' ), |
| 107 | ] ); |
| 108 | $this->hoistableMatcher->setDefaultOptions( [ 'skip-whitespace' => false ] ); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /** @inheritDoc */ |
| 113 | public function handlesRule( Rule $rule ) { |
| 114 | return $rule instanceof QualifiedRule; |
| 115 | } |
| 116 | |
| 117 | /** @inheritDoc */ |
| 118 | protected function doSanitize( CSSObject $object ) { |
| 119 | if ( !$object instanceof QualifiedRule ) { |
| 120 | $this->sanitizationError( 'expected-qualified-rule', $object ); |
| 121 | return null; |
| 122 | } |
| 123 | |
| 124 | // Test that the prelude is a valid selector list |
| 125 | $match = $this->selectorMatcher->matchAgainst( $object->getPrelude(), [ 'mark-significance' => true ] ); |
| 126 | if ( !$match ) { |
| 127 | $cv = Util::findFirstNonWhitespace( $object->getPrelude() ); |
| 128 | if ( $cv ) { |
| 129 | $this->sanitizationError( 'invalid-selector-list', $cv ); |
| 130 | } else { |
| 131 | $this->sanitizationError( 'missing-selector-list', $object ); |
| 132 | } |
| 133 | return null; |
| 134 | } |
| 135 | |
| 136 | $ret = clone $object; |
| 137 | |
| 138 | if ( $this->prependSelectors ) { |
| 139 | $prelude = $ret->getPrelude(); |
| 140 | $comma = [ |
| 141 | new Token( Token::T_COMMA ), |
| 142 | new Token( Token::T_WHITESPACE, [ 'significant' => false ] ) |
| 143 | ]; |
| 144 | $space = [ |
| 145 | new Token( Token::T_WHITESPACE, [ 'significant' => true ] ) |
| 146 | ]; |
| 147 | $prelude->clear(); |
| 148 | foreach ( $match->getCapturedMatches() as $selectorOrWs ) { |
| 149 | if ( $selectorOrWs->getName() === 'selector' ) { |
| 150 | if ( $prelude->count() ) { |
| 151 | $prelude->add( $comma ); |
| 152 | } |
| 153 | |
| 154 | $valueList = new ComponentValueList( $selectorOrWs->getValues() ); |
| 155 | $hoistMatch = $this->hoistableMatcher ? $this->hoistableMatcher->matchAgainst( $valueList ) : null; |
| 156 | if ( $hoistMatch ) { |
| 157 | [ $prefix, , $postfix ] = $hoistMatch->getCapturedMatches(); |
| 158 | $prelude->add( $prefix->getValues() ); |
| 159 | $prelude->add( $space ); |
| 160 | $prelude->add( $this->prependSelectors ); |
| 161 | $prelude->add( $space ); |
| 162 | $prelude->add( $postfix->getValues() ); |
| 163 | } else { |
| 164 | $prelude->add( $this->prependSelectors ); |
| 165 | $prelude->add( $space ); |
| 166 | $prelude->add( $valueList ); |
| 167 | } |
| 168 | } elseif ( $selectorOrWs->getName() === 'trailingWS' && $selectorOrWs->getLength() > 0 ) { |
| 169 | $prelude->add( $selectorOrWs->getValues() ); |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer ); |
| 175 | |
| 176 | return $ret; |
| 177 | } |
| 178 | |
| 179 | } |