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/2019/CR-css-syntax-3-20190716/#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 | } |