Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.21% |
583 / 765 |
|
56.52% |
26 / 46 |
CRAP | |
0.00% |
0 / 1 |
ZestInst | |
76.21% |
583 / 765 |
|
56.52% |
26 / 46 |
1053.28 | |
0.00% |
0 / 1 |
sort | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
6.00 | |||
next | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
prev | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
child | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
lastChild | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
parentIsElement | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
nodeIsDocument | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
unichr | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
unquote | |
60.00% |
12 / 20 |
|
0.00% |
0 / 1 |
12.10 | |||
decodeid | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
encodeid | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
makeInside | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
reSource | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
replace | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
truncateUrl | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
xpathQuote | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getElementsById | |
66.67% |
16 / 24 |
|
0.00% |
0 / 1 |
17.33 | |||
docFragHelper | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
getElementsByTagName | |
87.50% |
21 / 24 |
|
0.00% |
0 / 1 |
9.16 | |||
getElementsByClassName | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
4.06 | |||
parseNth | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
nth | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
10.09 | |||
addSelector0 | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addSelector1 | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
initSelectors | |
70.53% |
146 / 207 |
|
0.00% |
0 / 1 |
69.17 | |||
selectorsAttr | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
addOperator | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
initOperators | |
67.35% |
33 / 49 |
|
0.00% |
0 / 1 |
17.01 | |||
addCombinator | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
initCombinators | |
94.87% |
37 / 39 |
|
0.00% |
0 / 1 |
12.02 | |||
makeRef | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
90 | |||
initRules | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
1 | |||
compile | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
doCompile | |
68.75% |
44 / 64 |
|
0.00% |
0 / 1 |
21.87 | |||
tokQname | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
tok | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
10.09 | |||
makeSimple | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
makeTest | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
makeSubject | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
compileGroup | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
5.73 | |||
findInternal | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
8 | |||
find | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
9 | |||
matches | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
2.00 | |||
newBadSelectorException | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isStandardsMode | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
__construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace Wikimedia\Zest; |
4 | |
5 | use DOMDocument; |
6 | use DOMDocumentFragment; |
7 | use DOMElement; |
8 | use DOMNode; |
9 | use InvalidArgumentException; |
10 | use Throwable; |
11 | |
12 | /** |
13 | * Zest.php (https://github.com/wikimedia/zest.php) |
14 | * Copyright (c) 2019, C. Scott Ananian. (MIT licensed) |
15 | * PHP port based on: |
16 | * |
17 | * Zest (https://github.com/chjj/zest) |
18 | * A css selector engine. |
19 | * Copyright (c) 2011-2012, Christopher Jeffrey. (MIT Licensed) |
20 | * Domino version based on Zest v0.1.3 with bugfixes applied. |
21 | */ |
22 | |
23 | class ZestInst { |
24 | |
25 | /** @var ZestFunc[] */ |
26 | private $compileCache = []; |
27 | |
28 | /** |
29 | * Helpers |
30 | */ |
31 | |
32 | /** |
33 | * Sort query results in document order. |
34 | * @param array &$results |
35 | * @param bool $isStandardsMode |
36 | */ |
37 | private static function sort( &$results, bool $isStandardsMode ): void { |
38 | if ( count( $results ) < 2 ) { |
39 | return; |
40 | } |
41 | if ( $isStandardsMode ) { |
42 | // DOM spec-compliant version: |
43 | usort( $results, static function ( $a, $b ) { |
44 | return ( $a->compareDocumentPosition( $b ) & 2 ) ? 1 : -1; |
45 | } ); |
46 | } |
47 | // PHP's dom extension returns true for method_exists on |
48 | // compareDocumentPosition, but when called it throws a |
49 | // "Not yet implemented" exception |
50 | |
51 | // If compareDocumentPosition isn't implemented, skip the sort. |
52 | // Our results are generally added as a result of an in-order |
53 | // traversal of the tree, so absent any funny business with complex |
54 | // selectors, our natural order should be more-or-less sorted. |
55 | } |
56 | |
57 | /** |
58 | * @param DOMNode $el |
59 | * @return ?DOMNode |
60 | */ |
61 | private static function next( $el ) { |
62 | while ( ( $el = $el->nextSibling ) && $el->nodeType !== 1 ) { |
63 | // no op |
64 | } |
65 | return $el; |
66 | } |
67 | |
68 | /** |
69 | * @param DOMNode $el |
70 | * @return ?DOMNode |
71 | */ |
72 | private static function prev( $el ) { |
73 | while ( ( $el = $el->previousSibling ) && $el->nodeType !== 1 ) { |
74 | // no op |
75 | } |
76 | return $el; |
77 | } |
78 | |
79 | /** |
80 | * @param DOMNode $el |
81 | * @return ?DOMNode |
82 | */ |
83 | private static function child( $el ) { |
84 | if ( $el = $el->firstChild ) { |
85 | while ( $el->nodeType !== 1 && ( $el = $el->nextSibling ) ) { |
86 | // no op |
87 | } |
88 | } |
89 | return $el; |
90 | } |
91 | |
92 | /** |
93 | * @param DOMNode $el |
94 | * @return ?DOMNode |
95 | */ |
96 | private static function lastChild( $el ) { |
97 | if ( $el = $el->lastChild ) { |
98 | while ( $el->nodeType !== 1 && ( $el = $el->previousSibling ) ) { |
99 | // no op |
100 | } |
101 | } |
102 | return $el; |
103 | } |
104 | |
105 | /** |
106 | * @param DOMNode $n |
107 | * @return bool |
108 | */ |
109 | private static function parentIsElement( $n ): bool { |
110 | $parent = $n->parentNode; |
111 | if ( !$parent ) { |
112 | return false; |
113 | } |
114 | // The root `html` element (node type 9) can be a first- or |
115 | // last-child, too, which means that the document (or document |
116 | // fragment) counts as an "element". |
117 | return $parent->nodeType === 1 /* Element */ || |
118 | self::nodeIsDocument( $parent ) /* Document */ || |
119 | $parent->nodeType === 11; /* DocumentFragment */ |
120 | } |
121 | |
122 | /** |
123 | * @param DOMNode $n |
124 | * @return bool |
125 | */ |
126 | private static function nodeIsDocument( $n ): bool { |
127 | $nodeType = $n->nodeType; |
128 | return $nodeType === 9 /* Document */ || |
129 | // In PHP, if you load a document with |
130 | // DOMDocument::loadHTML, your root DOMDocument will have node |
131 | // type 13 (!) which is PHP's bespoke "XML_HTML_DOCUMENT_NODE" |
132 | // and Not A Real Thing. But we'll recognize it anyway... |
133 | $nodeType === 13; /* HTMLDocument */ |
134 | } |
135 | |
136 | private static function unichr( int $codepoint ): string { |
137 | if ( extension_loaded( 'intl' ) ) { |
138 | return \IntlChar::chr( $codepoint ); |
139 | } else { |
140 | return mb_chr( $codepoint, "utf-8" ); |
141 | } |
142 | } |
143 | |
144 | private static function unquote( string $str ): string { |
145 | if ( !$str ) { |
146 | return $str; |
147 | } |
148 | self::initRules(); |
149 | $ch = $str[ 0 ]; |
150 | if ( $ch === '"' || $ch === "'" ) { |
151 | if ( substr( $str, -1 ) === $ch ) { |
152 | $str = substr( $str, 1, -1 ); |
153 | } else { |
154 | // bad string. |
155 | $str = substr( $str, 1 ); |
156 | } |
157 | return preg_replace_callback( self::$rules->str_escape, function ( array $matches ) { |
158 | $s = $matches[0]; |
159 | if ( !preg_match( '/^\\\(?:([0-9A-Fa-f]+)|([\r\n\f]+))/', $s, $m ) ) { |
160 | return substr( $s, 1 ); |
161 | } |
162 | if ( $m[ 2 ] ) { |
163 | return ''; /* escaped newlines are ignored in strings. */ |
164 | } |
165 | $cp = intval( $m[ 1 ], 16 ); |
166 | return self::unichr( $cp ); |
167 | }, $str ); |
168 | } elseif ( preg_match( self::$rules->ident, $str ) ) { |
169 | return self::decodeid( $str ); |
170 | } else { |
171 | // NUMBER, PERCENTAGE, DIMENSION, etc |
172 | return $str; |
173 | } |
174 | } |
175 | |
176 | private static function decodeid( string $str ): string { |
177 | return preg_replace_callback( self::$rules->escape, function ( array $matches ) { |
178 | $s = $matches[0]; |
179 | if ( !preg_match( '/^\\\([0-9A-Fa-f]+)/', $s, $m ) ) { |
180 | return $s[ 1 ]; |
181 | } |
182 | $cp = intval( $m[ 1 ], 16 ); |
183 | return self::unichr( $cp ); |
184 | }, $str ); |
185 | } |
186 | |
187 | /** |
188 | * Escape an identifier for CSS. |
189 | * This is equivalent to CSS.escape |
190 | * (https://drafts.csswg.org/cssom/#the-css.escape()-method) |
191 | * and is the opposite of self::decodeid(). |
192 | */ |
193 | private static function encodeid( string $str ): string { |
194 | return preg_replace_callback( '/(\\x00)|([\\x01-\\x1F\\x7F])|(^[0-9])|(^-[0-9])|(^-$)|([^-A-Za-z0-9_\\x{80}-\\x{10FFFF}])/u', static function ( array $matches ) { |
195 | if ( isset( $matches[1] ) ) { |
196 | return "\u{FFFD}"; |
197 | } elseif ( isset( $matches[2] ) || isset( $matches[3] ) ) { |
198 | $cp = mb_ord( $matches[0], "UTF-8" ); |
199 | return '\\' . dechex( $cp ) . ' '; |
200 | } elseif ( isset( $matches[4] ) ) { |
201 | $cp = mb_ord( $matches[0][1], "UTF-8" ); |
202 | return '-\\' . dechex( $cp ) . ' '; |
203 | } else { |
204 | return '\\' . $matches[0]; |
205 | } |
206 | }, $str, -1, $ignore, PREG_UNMATCHED_AS_NULL ); |
207 | } |
208 | |
209 | private static function makeInside( string $start, string $end ): string { |
210 | $regex = preg_replace( |
211 | '/>/', $end, preg_replace( |
212 | '/</', $start, self::reSource( self::$rules->inside ) |
213 | ) |
214 | ); |
215 | return '/' . $regex . '/Su'; |
216 | } |
217 | |
218 | private static function reSource( string $regex ): string { |
219 | // strip delimiter and flags from regular expression |
220 | return preg_replace( '/(^\/)|(\/[a-z]*$)/Diu', '', $regex ); |
221 | } |
222 | |
223 | private static function replace( string $regex, string $name, string $val ): string { |
224 | $regex = self::reSource( $regex ); |
225 | $regex = str_replace( $name, self::reSource( $val ), $regex ); |
226 | return '/' . $regex . '/Su'; |
227 | } |
228 | |
229 | private static function truncateUrl( string $url, int $num ): string { |
230 | $url = preg_replace( '/^(?:\w+:\/\/|\/+)/', '', $url ); |
231 | $url = preg_replace( '/(?:\/+|\/*#.*?)$/', '', $url ); |
232 | return implode( '/', explode( '/', $url, $num ) ); |
233 | } |
234 | |
235 | private static function xpathQuote( string $s ): string { |
236 | // Ugly-but-functional escape mechanism for xpath query |
237 | $parts = explode( "'", $s ); |
238 | $parts = array_map( static function ( string $ss ) { |
239 | return "'$ss'"; |
240 | }, $parts ); |
241 | if ( count( $parts ) === 1 ) { |
242 | return $parts[0]; |
243 | } else { |
244 | return 'concat(' . implode( ',"\'",', $parts ) . ')'; |
245 | } |
246 | } |
247 | |
248 | /** |
249 | * Get descendants by ID. |
250 | * |
251 | * The PHP DOM doesn't provide this method for DOMElement, and the |
252 | * implementation in DOMDocument is broken. |
253 | * |
254 | * Further, the web spec only provides for returning a single element |
255 | * here. This function can support returning *all* of the matches for |
256 | * a given ID, if the underlying DOM implementation supports this. |
257 | * |
258 | * This is an *exclusive* query; that is, $context should never be included |
259 | * among the results. |
260 | * |
261 | * Although a `getElementsById` key can be passed in the options array |
262 | * to override the default implementation, for efficiency it is recommended |
263 | * that clients subclass ZestInst and override this entire method if |
264 | * they can provide an efficient id index. |
265 | * |
266 | * @param DOMDocument|DOMDocumentFragment|DOMElement $context |
267 | * The scoping root for the search |
268 | * @param string $id |
269 | * @param array $opts Additional match-context options (optional) |
270 | * @return array<DOMElement> A list of the elements with the given ID. When there are more |
271 | * than one, this method might return all of them or only the first one. |
272 | */ |
273 | public function getElementsById( $context, string $id, array $opts = [] ): array { |
274 | if ( is_callable( $opts['getElementsById'] ?? null ) ) { |
275 | // Third-party DOM implementation might provide a way to |
276 | // get multiple results for a given ID. |
277 | // Note that this must work for DocumentFragment and Element |
278 | // as well! |
279 | $func = $opts['getElementsById']; |
280 | return $func( $context, $id ); |
281 | } |
282 | // Neither PHP nor the web standards provide an DOMElement-scoped |
283 | // version of getElementById, so we can't call this directly on |
284 | // $context -- but that's okay because (1) IDs should be unique, and |
285 | // (2) we verify the scope of the returned element below |
286 | // anyway (to work around bugs with deleted-but-not-gc'ed |
287 | // nodes). |
288 | $doc = self::nodeIsDocument( $context ) ? |
289 | $context : $context->ownerDocument; |
290 | $r = $doc->getElementById( $id ); |
291 | // Note that $r could be null here because the |
292 | // DOMDocument hasn't had an "id attribute" set, even if the id |
293 | // exists in the document. See: |
294 | // http://php.net/manual/en/domdocument.getelementbyid.php |
295 | if ( $r !== null ) { |
296 | // Verify that this node is actually connected to the |
297 | // document (or to the context), since the element |
298 | // isn't removed from the index immediately when it |
299 | // is deleted. (Also PHP's call is not scoped.) |
300 | // (Note that scoped getElementsById is *exclusive* of $context, |
301 | // so we start this search at r's parent node.) |
302 | for ( $parent = $r->parentNode; $parent; $parent = $parent->parentNode ) { |
303 | if ( $parent === $context ) { |
304 | return [ $r ]; |
305 | } |
306 | } |
307 | // It's possible a deleted-but-still-indexed element was |
308 | // shadowing a later-added element, so we can't return |
309 | // null here directly; fallback to a full search. |
310 | } |
311 | if ( $this->isStandardsMode( $context, $opts ) ) { |
312 | // The workaround below only works (and is only necessary!) |
313 | // when this is a PHP-provided \DOMDocument. For 3rd-party |
314 | // DOM implementations, we assume that getElementById() was |
315 | // reliable. |
316 | // @phan-suppress-next-line PhanUndeclaredProperty |
317 | if ( $context->isConnected || $id === '' ) { |
318 | return []; |
319 | } |
320 | // For disconnected Elements and DocumentFragments, we need |
321 | // to do this the hard/slow way |
322 | $r = []; |
323 | foreach ( $this->getElementsByTagName( $context, '*', $opts ) as $el ) { |
324 | // Work around Phan 8.1 / 7.4 issues by telling Phan what |
325 | // the expected type is. Zest could be used with newer DOM |
326 | // implementations that could return null for missing attributes. |
327 | // See https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute#non-existing_attributes |
328 | // But, Phan doesn't know that and only sees the native PHP |
329 | // implementation that returns '' for missing attributes. |
330 | $elId = $el->getAttribute( 'id' ); |
331 | '@phan-var ?string $elId'; |
332 | if ( $id === ( $elId ?? '' ) ) { |
333 | $r[] = $el; |
334 | } |
335 | } |
336 | return $r; |
337 | } |
338 | // Do an xpath search, which is still a full traversal of the tree |
339 | // (sigh) but 25% faster than traversing it wholly in PHP. |
340 | $xpath = new \DOMXPath( $doc ); |
341 | $query = './/*[@id=' . self::xpathQuote( $id ) . ']'; |
342 | if ( $context->nodeType === 11 ) { |
343 | // ugh, PHP dom extension workaround: nodes which are direct |
344 | // children of the DocumentFragment are not matched unless we |
345 | // use a ./ query in addition to the .// query. |
346 | $query = "./" . substr( $query, 3 ) . "|$query"; |
347 | } |
348 | return iterator_to_array( $xpath->query( $query, $context ) ); |
349 | } |
350 | |
351 | private function docFragHelper( $docFrag, string $sel, array $opts, callable $collectFunc ) { |
352 | $result = []; |
353 | for ( $n = $docFrag->firstChild; $n; $n = $n->nextSibling ) { |
354 | if ( $n->nodeType !== 1 ) { |
355 | continue; // Not an element |
356 | } |
357 | // See if $n itself should be included |
358 | if ( $this->matches( $n, $sel, $opts ) ) { |
359 | $result[] = $n; |
360 | } |
361 | // Now include all of $n's children |
362 | array_splice( $result, count( $result ), 0, $collectFunc( $n ) ); |
363 | } |
364 | return $result; |
365 | } |
366 | |
367 | /** |
368 | * Get descendants by tag name. |
369 | * The PHP DOM doesn't provide this method for DOMElement, and the |
370 | * implementation in DOMDocument has performance issues. |
371 | * |
372 | * This is an *exclusive* query; that is, $context should never be included |
373 | * among the results. |
374 | * |
375 | * Clients can subclass and override this to provide a more efficient |
376 | * implementation if one is available. |
377 | * |
378 | * @param DOMDocument|DOMDocumentFragment|DOMElement $context |
379 | * @param string $tagName |
380 | * @param array $opts Additional match-context options (optional) |
381 | * @return array<DOMElement> |
382 | */ |
383 | public function getElementsByTagName( $context, string $tagName, array $opts = [] ) { |
384 | if ( $context->nodeType === 11 /* DocumentFragment */ ) { |
385 | // DOM standards don't define getElementsByTagName on |
386 | // DocumentFragment, and XPath supports it but has a bug which |
387 | // omits root elements. So emulate in both these cases. |
388 | $selector = $tagName === '*' ? '*' : self::encodeid( $tagName ); |
389 | return $this->docFragHelper( |
390 | $context, $selector, $opts, |
391 | function ( $el ) use ( $tagName, $opts ): array { |
392 | return $this->getElementsByTagName( $el, $tagName, $opts ); |
393 | } |
394 | ); |
395 | } |
396 | if ( $this->isStandardsMode( $context, $opts ) ) { |
397 | // For third-party DOM implementations, just use native func. |
398 | return iterator_to_array( |
399 | $context->getElementsByTagName( $tagName ) |
400 | ); |
401 | } |
402 | // This *should* just be a call to PHP's `getElementByTagName` |
403 | // function *BUT* PHP's implementation is 100x slower than using |
404 | // XPath to get the same results (!) |
405 | |
406 | // XXX this assumes default PHP DOM implementation, which |
407 | // reports lowercase tag names in DOMNode->tagName (even though |
408 | // the DOM spec says it should report uppercase) |
409 | $tagName = strtolower( $tagName ); |
410 | |
411 | $doc = self::nodeIsDocument( $context ) ? |
412 | $context : $context->ownerDocument; |
413 | $xpath = new \DOMXPath( $doc ); |
414 | $ns = $doc->documentElement === null ? 'force use of local-name' : |
415 | $doc->documentElement->namespaceURI; |
416 | if ( $tagName === '*' ) { |
417 | $query = ".//*"; |
418 | } elseif ( $ns || !preg_match( '/^[_a-z][-.0-9_a-z]*$/S', $tagName ) ) { |
419 | $query = './/*[local-name()=' . self::xpathQuote( $tagName ) . ']'; |
420 | } else { |
421 | $query = ".//$tagName"; |
422 | } |
423 | return iterator_to_array( $xpath->query( $query, $context ) ); |
424 | } |
425 | |
426 | /** |
427 | * Clients can subclass and override this to provide a more efficient |
428 | * implementation if one is available. |
429 | * |
430 | * This is an *exclusive* query; that is, $context should never be included |
431 | * among the results. |
432 | * |
433 | * @param DOMDocument|DOMDocumentFragment|DOMElement $context |
434 | * @param string $className |
435 | * @param array $opts |
436 | * @return array<DOMElement> |
437 | */ |
438 | protected function getElementsByClassName( $context, string $className, $opts ) { |
439 | if ( $context->nodeType === 11 /* DocumentFragment */ ) { |
440 | // DOM standards don't define getElementsByClassName on |
441 | // DocumentFragment, and XPath supports it but has a bug which |
442 | // omits root elements. So emulate in both these cases. |
443 | return $this->docFragHelper( |
444 | $context, |
445 | // NOTE this only works when $className is a single class, |
446 | // but that's the only way we invoke it. |
447 | "." . self::encodeid( $className ), |
448 | $opts, |
449 | function ( $el ) use ( $className, $opts ): array { |
450 | return $this->getElementsByClassName( $el, $className, $opts ); |
451 | } |
452 | ); |
453 | } |
454 | if ( $this->isStandardsMode( $context, $opts ) ) { |
455 | // For third-party DOM implementations, just use native func. |
456 | return iterator_to_array( |
457 | // @phan-suppress-next-line PhanUndeclaredMethod |
458 | $context->getElementsByClassName( $className ) |
459 | ); |
460 | } |
461 | |
462 | // PHP doesn't have an implementation of this method; use XPath |
463 | // to quickly get results. (It would be faster still if there was an |
464 | // actual index, but this will be about 25% faster than doing the |
465 | // tree traversal all in PHP.) |
466 | $doc = self::nodeIsDocument( $context ) ? |
467 | $context : $context->ownerDocument; |
468 | $xpath = new \DOMXPath( $doc ); |
469 | $quotedClassName = self::xpathQuote( " $className " ); |
470 | $query = ".//*[contains(concat(' ', normalize-space(@class), ' '), $quotedClassName)]"; |
471 | return iterator_to_array( $xpath->query( $query, $context ) ); |
472 | } |
473 | |
474 | /** |
475 | * Handle `nth` Selectors |
476 | */ |
477 | private static function parseNth( string $param ): object { |
478 | $param = preg_replace( '/\s+/', '', $param ); |
479 | |
480 | if ( $param === 'even' ) { |
481 | $param = '2n+0'; |
482 | } elseif ( $param === 'odd' ) { |
483 | $param = '2n+1'; |
484 | } elseif ( strpos( $param, 'n' ) === false ) { |
485 | $param = '0n' . $param; |
486 | } |
487 | |
488 | preg_match( '/^([+-])?(\d+)?n([+-])?(\d+)?$/', $param, $cap, PREG_UNMATCHED_AS_NULL ); |
489 | |
490 | $group = intval( ( $cap[1] ?? '' ) . ( $cap[2] ?? '1' ), 10 ); |
491 | $offset = intval( ( $cap[3] ?? '' ) . ( $cap[4] ?? '0' ), 10 ); |
492 | return (object)[ |
493 | 'group' => $group, |
494 | 'offset' => $offset, |
495 | ]; |
496 | } |
497 | |
498 | /** |
499 | * @param string $param |
500 | * @param callable(DOMNode,DOMNode,array):bool $test |
501 | * @param bool $last |
502 | * @return callable(DOMNode,array):bool |
503 | */ |
504 | private static function nth( string $param, callable $test, bool $last ): callable { |
505 | $param = self::parseNth( $param ); |
506 | $group = $param->group; |
507 | $offset = $param->offset; |
508 | $find = ( !$last ) ? [ self::class, 'child' ] : [ self::class, 'lastChild' ]; |
509 | $advance = ( !$last ) ? [ self::class, 'next' ] : [ self::class, 'prev' ]; |
510 | return function ( $el, array $opts ) use ( $find, $test, $offset, $group, $advance ): bool { |
511 | if ( !self::parentIsElement( $el ) ) { |
512 | return false; |
513 | } |
514 | |
515 | $rel = call_user_func( $find, $el->parentNode ); |
516 | $pos = 0; |
517 | |
518 | while ( $rel ) { |
519 | if ( call_user_func( $test, $rel, $el, $opts ) ) { |
520 | $pos++; |
521 | } |
522 | if ( $rel === $el ) { |
523 | $pos -= $offset; |
524 | return ( $group && $pos ) |
525 | ? ( $pos % $group ) === 0 && ( ( $pos < 0 ) === ( $group < 0 ) ) |
526 | : !$pos; |
527 | } |
528 | $rel = call_user_func( $advance, $rel ); |
529 | } |
530 | return false; |
531 | }; |
532 | } |
533 | |
534 | /** |
535 | * Simple Selectors which take no arguments. |
536 | * @var array<string,(callable(DOMNode,array):bool)> |
537 | */ |
538 | private $selectors0; |
539 | |
540 | /** |
541 | * Simple Selectors which take one argument. |
542 | * @var array<string,(callable(string,ZestInst):(callable(DOMNode,array):bool))> |
543 | */ |
544 | private $selectors1; |
545 | |
546 | /** |
547 | * Add a custom selector that takes no parameters. |
548 | * @param string $key Name of the selector |
549 | * @param callable(DOMNode,array):bool $func |
550 | * The selector match function |
551 | */ |
552 | public function addSelector0( string $key, callable $func ) { |
553 | $this->selectors0[$key] = $func; |
554 | } |
555 | |
556 | /** |
557 | * Add a custom selector that takes 1 parameter, which is passed as a |
558 | * string. |
559 | * @param string $key Name of the selector |
560 | * @param callable(string,ZestInst):(callable(DOMNode,array):bool)|callable(string):(callable(DOMNode,array):bool) $func |
561 | * The selector match function |
562 | */ |
563 | public function addSelector1( string $key, callable $func ) { |
564 | $this->selectors1[$key] = $func; |
565 | } |
566 | |
567 | private function initSelectors() { |
568 | // Careful: this method is only called once, on the singleton |
569 | // ZestInst, which all child ZestInst instances inherit their |
570 | // default selector lists from. But as a result $this is |
571 | // always the singleton; be sure to use the $self argument (for |
572 | // selector1) or $opts['this'] (for selector0) to access the |
573 | // dynamically-bound $this. |
574 | |
575 | $this->addSelector0( '*', static function ( $el, $opts ): bool { |
576 | return true; |
577 | } ); |
578 | $this->addSelector1( 'type', static function ( string $type ): callable { |
579 | $type = strtolower( $type ); |
580 | return static function ( $el, $opts ) use ( $type ): bool { |
581 | return strtolower( $el->nodeName ) === $type; |
582 | }; |
583 | } ); |
584 | $this->addSelector1( 'typeNoNS', static function ( string $type ): callable { |
585 | $type = strtolower( $type ); |
586 | return static function ( $el, $opts ) use ( $type ): bool { |
587 | return ( $el->namespaceURI ?? '' ) === '' && |
588 | strtolower( $el->nodeName ) === $type; |
589 | }; |
590 | } ); |
591 | $this->addSelector0( ':first-child', function ( $el, $opts ): bool { |
592 | return !self::prev( $el ) && self::parentIsElement( $el ); |
593 | } ); |
594 | $this->addSelector0( ':last-child', function ( $el, $opts ): bool { |
595 | return !self::next( $el ) && self::parentIsElement( $el ); |
596 | } ); |
597 | $this->addSelector0( ':only-child', function ( $el, $opts ): bool { |
598 | return !self::prev( $el ) && !self::next( $el ) |
599 | && self::parentIsElement( $el ); |
600 | } ); |
601 | $this->addSelector1( ':nth-child', function ( string $param ): callable { |
602 | return self::nth( $param, static function ( $rel, $el, $opts ): bool { |
603 | return true; |
604 | }, false /* last */ ); |
605 | } ); |
606 | $this->addSelector1( ':nth-last-child', function ( string $param ): callable { |
607 | return self::nth( $param, static function ( $rel, $el, $opts ): bool { |
608 | return true; |
609 | }, true /* last */ ); |
610 | } ); |
611 | $this->addSelector0( ':root', static function ( $el, $opts ): bool { |
612 | return $el->ownerDocument->documentElement === $el; |
613 | } ); |
614 | $this->addSelector0( ':empty', static function ( $el, $opts ): bool { |
615 | return !$el->firstChild; |
616 | } ); |
617 | $this->addSelector1( ':not', static function ( string $sel, ZestInst $self ) { |
618 | $test = $self->compileGroup( $sel ); |
619 | return static function ( $el, $opts ) use ( $test ): bool { |
620 | return !call_user_func( $test, $el, $opts ); |
621 | }; |
622 | } ); |
623 | $this->addSelector0( ':first-of-type', function ( $el, $opts ): bool { |
624 | if ( !self::parentIsElement( $el ) ) { |
625 | return false; |
626 | } |
627 | $type = $el->nodeName; |
628 | while ( $el = self::prev( $el ) ) { |
629 | if ( $el->nodeName === $type ) { |
630 | return false; |
631 | } |
632 | } |
633 | return true; |
634 | } ); |
635 | $this->addSelector0( ':last-of-type', function ( $el, $opts ): bool { |
636 | if ( !self::parentIsElement( $el ) ) { |
637 | return false; |
638 | } |
639 | $type = $el->nodeName; |
640 | while ( $el = self::next( $el ) ) { |
641 | if ( $el->nodeName === $type ) { |
642 | return false; |
643 | } |
644 | } |
645 | return true; |
646 | } ); |
647 | $this->addSelector0( ':only-of-type', static function ( $el, $opts ): bool { |
648 | $self = $opts['this']; |
649 | return $self->selectors0[ ':first-of-type' ]( $el, $opts ) && |
650 | $self->selectors0[ ':last-of-type' ]( $el, $opts ); |
651 | } ); |
652 | $makeNthOfType = static function ( bool $last ) { |
653 | return function ( string $param ) use ( $last ): callable { |
654 | return self::nth( $param, static function ( $rel, $el, $opts ): bool { |
655 | return $rel->nodeName === $el->nodeName; |
656 | }, $last ); |
657 | }; |
658 | }; |
659 | $this->addSelector1( ':nth-of-type', $makeNthOfType( false ) ); |
660 | $this->addSelector1( ':nth-last-of-type', $makeNthOfType( true ) ); |
661 | /** @suppress PhanUndeclaredProperty not defined in PHP DOM */ |
662 | $this->addSelector0( ':checked', static function ( $el, $opts ): bool { |
663 | '@phan-var DOMElement $el'; |
664 | $self = $opts['this']; |
665 | if ( $self->isStandardsMode( $el, $opts ) ) { |
666 | // These properties don't exist in the PHP DOM, and in fact |
667 | // they are supposed to reflect the *dynamic* state of the |
668 | // widget, not the 'default' state (which is given by the |
669 | // attribute value) |
670 | if ( isset( $el->checked ) || isset( $el->selected ) ) { |
671 | return ( isset( $el->checked ) && $el->checked ) || |
672 | ( isset( $el->selected ) && $el->selected ); |
673 | } |
674 | } |
675 | return $el->hasAttribute( 'checked' ) || $el->hasAttribute( 'selected' ); |
676 | } ); |
677 | $this->addSelector0( ':indeterminate', static function ( $el, $opts ): bool { |
678 | $self = $opts['this']; |
679 | return !$self->selectors0[ ':checked' ]( $el, $opts ); |
680 | } ); |
681 | /** @suppress PhanUndeclaredProperty not defined in PHP DOM */ |
682 | $this->addSelector0( ':enabled', static function ( $el, $opts ): bool { |
683 | '@phan-var DOMElement $el'; |
684 | $self = $opts['this']; |
685 | if ( $self->isStandardsMode( $el, $opts ) && isset( $el->type ) ) { |
686 | $type = $el->type; // this does case normalization in spec |
687 | } else { |
688 | $type = $el->getAttribute( 'type' ); |
689 | } |
690 | return !$el->hasAttribute( 'disabled' ) && $type !== 'hidden'; |
691 | } ); |
692 | $this->addSelector0( ':disabled', static function ( $el, $opts ): bool { |
693 | '@phan-var DOMElement $el'; |
694 | return $el->hasAttribute( 'disabled' ); |
695 | } ); |
696 | /* |
697 | $this->addSelector0( ':target', function ( $el ) use ( &$window ) { |
698 | return $el->id === $window->location->hash->substring( 1 ); |
699 | }); |
700 | $this->addSelector0( ':focus', function ( $el ) { |
701 | return $el === $el->ownerDocument->activeElement; |
702 | }); |
703 | */ |
704 | $this->addSelector1( ':is', static function ( string $sel, ZestInst $self ): callable { |
705 | return $self->compileGroup( $sel ); |
706 | } ); |
707 | // :matches is an older name for :is; see |
708 | // https://github.com/w3c/csswg-drafts/issues/3258 |
709 | $this->addSelector1( ':matches', static function ( string $sel, ZestInst $self ): callable { |
710 | return $self->selectors1[ ':is' ]( $sel, $self ); |
711 | } ); |
712 | $makeNthMatch = static function ( bool $last ) { |
713 | return function ( string $param, ZestInst $self ) use ( $last ): callable { |
714 | $args = preg_split( '/\s*,\s*/', $param ); |
715 | $arg = array_shift( $args ); |
716 | $test = $self->compileGroup( implode( ',', $args ) ); |
717 | |
718 | return self::nth( $arg, static function ( $rel, $el, $opts ) use ( $test ): bool { |
719 | return call_user_func( $test, $el, $opts ); |
720 | }, $last ); |
721 | }; |
722 | }; |
723 | $this->addSelector1( ':nth-match', $makeNthMatch( false ) ); |
724 | $this->addSelector1( ':nth-last-match', $makeNthMatch( true ) ); |
725 | /* |
726 | $this->addSelector0( ':links-here', function ( $el ) use ( &$window ) { |
727 | return $el . '' === $window->location . ''; |
728 | }); |
729 | */ |
730 | $this->addSelector1( ':lang', static function ( string $param ): callable { |
731 | return static function ( $el, $opts ) use ( $param ): bool { |
732 | while ( $el ) { |
733 | if ( $el->nodeType === 1 /* Element */ ) { |
734 | '@phan-var DOMElement $el'; |
735 | // PHP DOM doesn't have 'lang' property |
736 | $lang = $el->getAttribute( 'lang' ); |
737 | if ( $lang ) { |
738 | return strpos( $lang, $param ) === 0; |
739 | } |
740 | } |
741 | $el = $el->parentNode; |
742 | } |
743 | return false; |
744 | }; |
745 | } ); |
746 | $this->addSelector1( ':dir', static function ( string $param ): callable { |
747 | return static function ( $el, $opts ) use ( $param ): bool { |
748 | while ( $el ) { |
749 | if ( $el->nodeType === 1 /* Element */ ) { |
750 | '@phan-var DOMElement $el'; |
751 | $dir = $el->getAttribute( 'dir' ); |
752 | if ( $dir ) { |
753 | return $dir === $param; |
754 | } |
755 | } |
756 | $el = $el->parentNode; |
757 | } |
758 | return false; |
759 | }; |
760 | } ); |
761 | $this->addSelector0( ':scope', static function ( $el, $opts ): bool { |
762 | $self = $opts['this']; |
763 | $scope = $opts['scope'] ?? null; |
764 | if ( $scope !== null && $scope->nodeType === 1 ) { |
765 | return $el === $scope; |
766 | } |
767 | // If the scoping root is missing or not an element, then :scope |
768 | // should be a synonym for :root |
769 | return $self->selectors0[ ':root' ]( $el, $opts ); |
770 | } ); |
771 | /* |
772 | $this->addSelector0( ':any-link', function ( $el ):bool { |
773 | return gettype( $el->href ) === 'string'; |
774 | }); |
775 | $this->addSelector( ':local-link', function ( $el ) use ( &$window ) { |
776 | if ( $el->nodeName ) { |
777 | return $el->href && $el->host === $window->location->host; |
778 | } |
779 | // XXX this is really selector1 not selector0 |
780 | $param = +$el + 1; |
781 | return function ( $el ) use ( &$window, $param ) { |
782 | if ( !$el->href ) { return; } |
783 | |
784 | $url = $window->location . ''; |
785 | $href = $el . ''; |
786 | |
787 | return self::truncateUrl( $url, $param ) === self::truncateUrl( $href, $param ); |
788 | }; |
789 | }); |
790 | $this->addSelector0( ':default', function ( $el ):bool { |
791 | return !!$el->defaultSelected; |
792 | }); |
793 | $this->addSelector0( ':valid', function ( $el ):bool { |
794 | return $el->willValidate || ( $el->validity && $el->validity->valid ); |
795 | }); |
796 | */ |
797 | $this->addSelector0( ':invalid', static function ( $el, $opts ): bool { |
798 | $self = $opts['this']; |
799 | return !$self->selectors0[ ':valid' ]( $el, $opts ); |
800 | } ); |
801 | /* |
802 | $this->addSelector0( ':in-range', function ( $el ):bool { |
803 | return $el->value > $el->min && $el->value <= $el->max; |
804 | }); |
805 | */ |
806 | $this->addSelector0( ':out-of-range', static function ( $el, $opts ): bool { |
807 | $self = $opts['this']; |
808 | return !$self->selectors0[ ':in-range' ]( $el, $opts ); |
809 | } ); |
810 | $this->addSelector0( ':required', static function ( $el, $opts ): bool { |
811 | '@phan-var DOMElement $el'; |
812 | return $el->hasAttribute( 'required' ); |
813 | } ); |
814 | $this->addSelector0( ':optional', static function ( $el, $opts ): bool { |
815 | $self = $opts['this']; |
816 | return !$self->selectors0[ ':required' ]( $el, $opts ); |
817 | } ); |
818 | $this->addSelector0( ':read-only', static function ( $el, $opts ): bool { |
819 | '@phan-var DOMElement $el'; |
820 | if ( $el->hasAttribute( 'readOnly' ) ) { |
821 | return true; |
822 | } |
823 | |
824 | $attr = $el->getAttribute( 'contenteditable' ); |
825 | $name = strtolower( $el->nodeName ); |
826 | |
827 | $name = $name !== 'input' && $name !== 'textarea'; |
828 | |
829 | return ( $name || $el->hasAttribute( 'disabled' ) ) && $attr == null; |
830 | } ); |
831 | $this->addSelector0( ':read-write', static function ( $el, $opts ): bool { |
832 | $self = $opts['this']; |
833 | return !$self->selectors0[ ':read-only' ]( $el, $opts ); |
834 | } ); |
835 | foreach ( [ |
836 | ':hover', |
837 | ':active', |
838 | ':link', |
839 | ':visited', |
840 | ':column', |
841 | ':nth-column', |
842 | ':nth-last-column', |
843 | ':current', |
844 | ':past', |
845 | ':future', |
846 | ] as $selector ) { |
847 | $this->addSelector0( |
848 | $selector, |
849 | /** |
850 | * @param DOMNode $el |
851 | * @param array $opts |
852 | * @return never |
853 | */ |
854 | static function ( $el, $opts ) use ( $selector ): bool { |
855 | $self = $opts['this']; |
856 | throw $self->newBadSelectorException( $selector . ' is not supported.' ); |
857 | } |
858 | ); |
859 | } |
860 | // Non-standard, for compatibility purposes. |
861 | $this->addSelector1( ':contains', static function ( string $param ): callable { |
862 | return static function ( $el ) use ( $param ): bool { |
863 | $text = $el->textContent; |
864 | return strpos( $text, $param ) !== false; |
865 | }; |
866 | } ); |
867 | $this->addSelector1( ':has', static function ( string $param ): callable { |
868 | return static function ( $el, array $opts ) use ( $param ): bool { |
869 | '@phan-var DOMElement $el'; |
870 | $self = $opts['this']; |
871 | return count( $self->find( $param, $el, $opts ) ) > 0; |
872 | }; |
873 | } ); |
874 | // Potentially add more pseudo selectors for |
875 | // compatibility with sizzle and most other |
876 | // selector engines (?). |
877 | } |
878 | |
879 | /** @return callable(DOMNode,array):bool */ |
880 | private function selectorsAttr( string $key, string $op, string $val, bool $i ): callable { |
881 | $op = $this->operators[ $op ]; |
882 | return static function ( $el, $opts ) use ( $key, $i, $op, $val ): bool { |
883 | /* XXX: the below all assumes a more complete PHP DOM than we have |
884 | switch ( $key ) { |
885 | #case 'for': |
886 | # $attr = $el->htmlFor; // Not supported in PHP DOM |
887 | # break; |
888 | case 'class': |
889 | // PHP DOM doesn't support $el->className |
890 | // className is '' when non-existent |
891 | // getAttribute('class') is null |
892 | if ($el->hasAttributes() && $el->hasAttribute( 'class' ) ) { |
893 | $attr = $el->getAttribute( 'class' ); |
894 | } else { |
895 | $attr = null; |
896 | } |
897 | break; |
898 | case 'href': |
899 | case 'src': |
900 | $attr = $el->getAttribute( $key, 2 ); |
901 | break; |
902 | case 'title': |
903 | // getAttribute('title') can be '' when non-existent sometimes? |
904 | if ($el->hasAttribute('title')) { |
905 | $attr = $el->getAttribute( 'title' ); |
906 | } else { |
907 | $attr = null; |
908 | } |
909 | break; |
910 | // careful with attributes with special getter functions |
911 | case 'id': |
912 | case 'lang': |
913 | case 'dir': |
914 | case 'accessKey': |
915 | case 'hidden': |
916 | case 'tabIndex': |
917 | case 'style': |
918 | if ( $el->getAttribute ) { |
919 | $attr = $el->getAttribute( $key ); |
920 | break; |
921 | } |
922 | // falls through |
923 | default: |
924 | if ( $el->hasAttribute && !$el->hasAttribute( $key ) ) { |
925 | break; |
926 | } |
927 | $attr = ( $el[ $key ] != null ) ? |
928 | $el[ $key ] : |
929 | $el->getAttribute && $el->getAttribute( $key ); |
930 | break; |
931 | } |
932 | */ |
933 | // This is our simple PHP DOM version |
934 | '@phan-var DOMElement $el'; |
935 | if ( $el->hasAttributes() && $el->hasAttribute( $key ) ) { |
936 | $attr = $el->getAttribute( $key ); |
937 | } else { |
938 | return false; |
939 | } |
940 | // End simple PHP DOM version |
941 | if ( $i ) { |
942 | $attr = strtolower( $attr ); |
943 | $val = strtolower( $val ); |
944 | } |
945 | return call_user_func( $op, $attr, $val ); |
946 | }; |
947 | } |
948 | |
949 | /** |
950 | * Attribute Operators |
951 | * @var array<string,(callable(string,string):bool)> |
952 | */ |
953 | private $operators; |
954 | |
955 | /** |
956 | * Add a custom operator |
957 | * @param string $key Name of the operator |
958 | * @param callable(string,string):bool $func |
959 | * The operator match function |
960 | */ |
961 | public function addOperator( string $key, callable $func ) { |
962 | $this->operators[$key] = $func; |
963 | } |
964 | |
965 | private function initOperators() { |
966 | // Be careful: $this always points to the singleton ZestInst |
967 | $this->addOperator( '-', static function ( string $attr, string $val ): bool { |
968 | return true; |
969 | } ); |
970 | $this->addOperator( '=', static function ( string $attr, string $val ): bool { |
971 | return $attr === $val; |
972 | } ); |
973 | $this->addOperator( '*=', static function ( string $attr, string $val ): bool { |
974 | return strpos( $attr, $val ) !== false; |
975 | } ); |
976 | $this->addOperator( '~=', static function ( string $attr, string $val ): bool { |
977 | // https://drafts.csswg.org/selectors-4/#attribute-representation |
978 | // If "val" contains whitespace, it will never represent |
979 | // anything (since the words are separated by spaces) |
980 | if ( strcspn( $val, " \t\r\n\f" ) !== strlen( $val ) ) { |
981 | return false; |
982 | } |
983 | // Also if "val" is the empty string, it will never |
984 | // represent anything. |
985 | if ( strlen( $val ) === 0 ) { |
986 | return false; |
987 | } |
988 | $attrLen = strlen( $attr ); |
989 | $valLen = strlen( $val ); |
990 | for ( $s = 0; $s < $attrLen; $s = $i + 1 ) { |
991 | $i = strpos( $attr, $val, $s ); |
992 | if ( $i === false ) { |
993 | return false; |
994 | } |
995 | $j = $i + $valLen; |
996 | $f = ( $i === 0 ) ? ' ' : $attr[ $i - 1 ]; |
997 | $l = ( $j >= $attrLen ) ? ' ' : $attr[ $j ]; |
998 | $f = strtr( $f, "\t\r\n\f", " " ); |
999 | $l = strtr( $l, "\t\r\n\f", " " ); |
1000 | if ( $f === ' ' && $l === ' ' ) { |
1001 | return true; |
1002 | } |
1003 | } |
1004 | return false; |
1005 | } ); |
1006 | $this->addOperator( '|=', static function ( string $attr, string $val ): bool { |
1007 | $i = strpos( $attr, $val ); |
1008 | if ( $i !== 0 ) { |
1009 | return false; |
1010 | } |
1011 | $j = $i + strlen( $val ); |
1012 | if ( $j >= strlen( $attr ) ) { |
1013 | return true; |
1014 | } |
1015 | $l = $attr[ $j ]; |
1016 | return $l === '-'; |
1017 | } ); |
1018 | $this->addOperator( '^=', static function ( string $attr, string $val ): bool { |
1019 | return strpos( $attr, $val ) === 0; |
1020 | } ); |
1021 | $this->addOperator( '$=', static function ( string $attr, string $val ): bool { |
1022 | $i = strrpos( $attr, $val ); |
1023 | return $i !== false && $i + strlen( $val ) === strlen( $attr ); |
1024 | } ); |
1025 | // non-standard |
1026 | $this->addOperator( '!=', static function ( string $attr, string $val ): bool { |
1027 | return $attr !== $val; |
1028 | } ); |
1029 | } |
1030 | |
1031 | /** |
1032 | * Combinator Logic |
1033 | * @var array<string,(callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)))> |
1034 | */ |
1035 | private $combinators; |
1036 | |
1037 | /** |
1038 | * Add a custom combinator |
1039 | * @param string $key Name of the combinator |
1040 | * @param callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)) $func |
1041 | * The combinator match function |
1042 | */ |
1043 | public function addCombinator( string $key, callable $func ) { |
1044 | $this->combinators[$key] = $func; |
1045 | } |
1046 | |
1047 | private function initCombinators() { |
1048 | // Be careful: $this always points to the singleton ZestInst |
1049 | $this->addCombinator( ' ', static function ( callable $test ): callable { |
1050 | return static function ( $el, $opts ) use ( $test ) { |
1051 | while ( $el = $el->parentNode ) { |
1052 | if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) { |
1053 | return $el; |
1054 | } |
1055 | } |
1056 | return null; |
1057 | }; |
1058 | } ); |
1059 | $this->addCombinator( '>', static function ( callable $test ): callable { |
1060 | return static function ( $el, $opts ) use ( $test ) { |
1061 | if ( $el = $el->parentNode ) { |
1062 | if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) { |
1063 | return $el; |
1064 | } |
1065 | } |
1066 | return null; |
1067 | }; |
1068 | } ); |
1069 | $this->addCombinator( '+', function ( callable $test ): callable { |
1070 | return function ( $el, $opts ) use ( $test ) { |
1071 | if ( $el = self::prev( $el ) ) { |
1072 | if ( call_user_func( $test, $el, $opts ) ) { |
1073 | return $el; |
1074 | } |
1075 | } |
1076 | return null; |
1077 | }; |
1078 | } ); |
1079 | $this->addCombinator( '~', function ( callable $test ): callable { |
1080 | return function ( $el, $opts ) use ( $test ) { |
1081 | while ( $el = self::prev( $el ) ) { |
1082 | if ( call_user_func( $test, $el, $opts ) ) { |
1083 | return $el; |
1084 | } |
1085 | } |
1086 | return null; |
1087 | }; |
1088 | } ); |
1089 | $this->addCombinator( 'noop', static function ( callable $test ): callable { |
1090 | return static function ( $el, $opts ) use ( $test ) { |
1091 | if ( call_user_func( $test, $el, $opts ) ) { |
1092 | return $el; |
1093 | } |
1094 | return null; |
1095 | }; |
1096 | } ); |
1097 | } |
1098 | |
1099 | /** |
1100 | * @param callable(DOMNode,array):bool $test |
1101 | * @param string $name |
1102 | * @return ZestFunc |
1103 | */ |
1104 | private function makeRef( callable $test, string $name ): ZestFunc { |
1105 | $node = null; |
1106 | $ref = new ZestFunc( function ( $el, $opts ) use ( &$node, &$ref ): bool { |
1107 | $doc = $el->ownerDocument; |
1108 | $nodes = $this->getElementsByTagName( $doc, '*', $opts ); |
1109 | $i = count( $nodes ); |
1110 | |
1111 | while ( $i-- ) { |
1112 | $node = $nodes[$i]; |
1113 | if ( call_user_func( $ref->test->func, $el, $opts ) ) { |
1114 | $node = null; |
1115 | return true; |
1116 | } |
1117 | } |
1118 | |
1119 | $node = null; |
1120 | return false; |
1121 | } ); |
1122 | |
1123 | $ref->combinator = static function ( $el, $opts ) use ( &$node, $name, $test ) { |
1124 | if ( !$node || $node->nodeType !== 1 /* Element */ ) { |
1125 | return null; |
1126 | } |
1127 | |
1128 | $attr = $node->getAttribute( $name ) ?? ''; |
1129 | if ( $attr !== '' && $attr[ 0 ] === '#' ) { |
1130 | $attr = substr( $attr, 1 ); |
1131 | } |
1132 | |
1133 | $id = $node->getAttribute( 'id' ) ?? ''; |
1134 | if ( $attr === $id && call_user_func( $test, $node, $opts ) ) { |
1135 | return $node; |
1136 | } |
1137 | return null; |
1138 | }; |
1139 | |
1140 | return $ref; |
1141 | } |
1142 | |
1143 | /** |
1144 | * Grammar |
1145 | */ |
1146 | |
1147 | /** @var \stdClass */ |
1148 | private static $rules; |
1149 | |
1150 | public static function initRules() { |
1151 | self::$rules = (object)[ |
1152 | 'escape' => '/\\\(?:[^0-9A-Fa-f\r\n]|[0-9A-Fa-f]{1,6}[\r\n\t ]?)/', |
1153 | 'str_escape' => '/(escape)|\\\(\n|\r\n?|\f)/', |
1154 | 'nonascii' => '/[\x{00A0}-\x{FFFF}]/', |
1155 | 'cssid' => '/(?:(?!-?[0-9])(?:escape|nonascii|[-_a-zA-Z0-9])+)/', |
1156 | 'qname' => '/^ *((?:\*?\|)?cssid|\*)/', |
1157 | 'simple' => '/^(?:([.#]cssid)|pseudo|attr)/', |
1158 | 'ref' => '/^ *\/(cssid)\/ */', |
1159 | 'combinator' => '/^(?: +([^ \w*.#\\\]) +|( )+|([^ \w*.#\\\]))(?! *$)/', |
1160 | 'attr' => '/^\[(cssid)(?:([^\w]?=)(inside))?\]/', |
1161 | 'pseudo' => '/^(:cssid)(?:\((inside)\))?/', |
1162 | 'inside' => "/(?:\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'|<[^\"'>]*>|\\\\[\"'>]|[^\"'>])*/", |
1163 | 'ident' => '/^(cssid)$/', |
1164 | ]; |
1165 | self::$rules->cssid = self::replace( self::$rules->cssid, 'nonascii', self::$rules->nonascii ); |
1166 | self::$rules->cssid = self::replace( self::$rules->cssid, 'escape', self::$rules->escape ); |
1167 | self::$rules->qname = self::replace( self::$rules->qname, 'cssid', self::$rules->cssid ); |
1168 | self::$rules->simple = self::replace( self::$rules->simple, 'cssid', self::$rules->cssid ); |
1169 | self::$rules->ref = self::replace( self::$rules->ref, 'cssid', self::$rules->cssid ); |
1170 | self::$rules->attr = self::replace( self::$rules->attr, 'cssid', self::$rules->cssid ); |
1171 | self::$rules->pseudo = self::replace( self::$rules->pseudo, 'cssid', self::$rules->cssid ); |
1172 | self::$rules->inside = self::replace( self::$rules->inside, "[^\"'>]*", self::$rules->inside ); |
1173 | self::$rules->attr = self::replace( self::$rules->attr, 'inside', self::makeInside( '\[', '\]' ) ); |
1174 | self::$rules->pseudo = self::replace( self::$rules->pseudo, 'inside', self::makeInside( '\(', '\)' ) ); |
1175 | self::$rules->simple = self::replace( self::$rules->simple, 'pseudo', self::$rules->pseudo ); |
1176 | self::$rules->simple = self::replace( self::$rules->simple, 'attr', self::$rules->attr ); |
1177 | self::$rules->ident = self::replace( self::$rules->ident, 'cssid', self::$rules->cssid ); |
1178 | self::$rules->str_escape = self::replace( self::$rules->str_escape, 'escape', self::$rules->escape ); |
1179 | } |
1180 | |
1181 | /** |
1182 | * Compiling |
1183 | */ |
1184 | |
1185 | private function compile( string $sel ): ZestFunc { |
1186 | if ( !isset( $this->compileCache[$sel] ) ) { |
1187 | $this->compileCache[$sel] = $this->doCompile( $sel ); |
1188 | } |
1189 | return $this->compileCache[$sel]; |
1190 | } |
1191 | |
1192 | private function doCompile( string $sel ): ZestFunc { |
1193 | $sel = preg_replace( '/^\s+|\s+$/', '', $sel ); |
1194 | $test = null; |
1195 | $filter = []; |
1196 | $buff = []; |
1197 | $subject = null; |
1198 | $qname = null; |
1199 | $cap = null; |
1200 | $op = null; |
1201 | $ref = null; |
1202 | |
1203 | while ( $sel ) { |
1204 | if ( preg_match( self::$rules->qname, $sel, $cap ) ) { |
1205 | $sel = substr( $sel, strlen( $cap[0] ) ); |
1206 | $qname = self::decodeid( $cap[ 1 ] ); |
1207 | $buff[] = $this->tokQname( $qname ); |
1208 | // strip off *| or | prefix |
1209 | if ( substr( $qname, 0, 1 ) === '|' ) { |
1210 | $qname = substr( $qname, 1 ); |
1211 | } elseif ( substr( $qname, 0, 2 ) === '*|' ) { |
1212 | $qname = substr( $qname, 2 ); |
1213 | } |
1214 | } elseif ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) { |
1215 | $sel = substr( $sel, strlen( $cap[0] ) ); |
1216 | $qname = '*'; |
1217 | $buff[] = $this->tokQname( $qname ); |
1218 | $buff[] = $this->tok( $cap ); |
1219 | } else { |
1220 | throw $this->newBadSelectorException( 'Invalid selector.' ); |
1221 | } |
1222 | |
1223 | while ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) { |
1224 | $sel = substr( $sel, strlen( $cap[0] ) ); |
1225 | $buff[] = $this->tok( $cap ); |
1226 | } |
1227 | |
1228 | if ( $sel && $sel[ 0 ] === '!' ) { |
1229 | $sel = substr( $sel, 1 ); |
1230 | $subject = $this->makeSubject(); |
1231 | $subject->qname = $qname; |
1232 | $buff[] = $subject->simple; |
1233 | } |
1234 | |
1235 | if ( preg_match( self::$rules->ref, $sel, $cap ) ) { |
1236 | $sel = substr( $sel, strlen( $cap[0] ) ); |
1237 | $ref = $this->makeRef( self::makeSimple( $buff ), self::decodeid( $cap[ 1 ] ) ); |
1238 | $filter[] = $ref->combinator; |
1239 | $buff = []; |
1240 | continue; |
1241 | } |
1242 | |
1243 | if ( preg_match( self::$rules->combinator, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) { |
1244 | $sel = substr( $sel, strlen( $cap[0] ) ); |
1245 | $op = $cap[ 1 ] ?? $cap[ 2 ] ?? $cap[ 3 ]; |
1246 | if ( $op === ',' ) { |
1247 | $filter[] = $this->combinators['noop']( self::makeSimple( $buff ) ); |
1248 | break; |
1249 | } |
1250 | } else { |
1251 | $op = 'noop'; |
1252 | } |
1253 | |
1254 | if ( !isset( $this->combinators[ $op ] ) ) { |
1255 | throw $this->newBadSelectorException( 'Bad combinator: ' . $op ); |
1256 | } |
1257 | $filter[] = $this->combinators[ $op ]( self::makeSimple( $buff ) ); |
1258 | $buff = []; |
1259 | } |
1260 | |
1261 | $test = self::makeTest( $filter ); |
1262 | $test->qname = $qname; |
1263 | $test->sel = $sel; |
1264 | |
1265 | if ( $subject ) { |
1266 | $subject->lname = $test->qname; |
1267 | |
1268 | $subject->test = $test; |
1269 | // @phan-suppress-next-line PhanPluginDuplicateExpressionAssignment |
1270 | $subject->qname = $subject->qname; |
1271 | $subject->sel = $test->sel; |
1272 | $test = $subject; |
1273 | } |
1274 | |
1275 | if ( $ref ) { |
1276 | $ref->test = $test; |
1277 | $ref->qname = $test->qname; |
1278 | $ref->sel = $test->sel; |
1279 | $test = $ref; |
1280 | } |
1281 | |
1282 | return $test; |
1283 | } |
1284 | |
1285 | /** @return callable(DOMNode,array):bool */ |
1286 | private function tokQname( string $cap ): callable { |
1287 | // qname |
1288 | if ( $cap === '*' ) { |
1289 | return $this->selectors0['*']; |
1290 | } elseif ( substr( $cap, 0, 1 ) === '|' ) { |
1291 | // no namespace |
1292 | return $this->selectors1['typeNoNS']( substr( $cap, 1 ), $this ); |
1293 | } elseif ( substr( $cap, 0, 2 ) === '*|' ) { |
1294 | // any namespace including no namespace |
1295 | return $this->selectors1['type']( substr( $cap, 2 ), $this ); |
1296 | } else { |
1297 | return $this->selectors1['type']( $cap, $this ); |
1298 | } |
1299 | } |
1300 | |
1301 | /** @return callable(DOMNode,array):bool */ |
1302 | private function tok( array $cap ): callable { |
1303 | // class/id |
1304 | if ( $cap[ 1 ] ) { |
1305 | return $cap[ 1 ][ 0 ] === '.' |
1306 | // XXX unescape here? or in attr? |
1307 | ? $this->selectorsAttr( 'class', '~=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false ) : |
1308 | $this->selectorsAttr( 'id', '=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false ); |
1309 | } |
1310 | |
1311 | // pseudo-name |
1312 | // inside-pseudo |
1313 | if ( $cap[ 2 ] ) { |
1314 | $id = self::decodeid( $cap[ 2 ] ); |
1315 | if ( isset( $cap[3] ) && $cap[ 3 ] ) { |
1316 | if ( !isset( $this->selectors1[ $id ] ) ) { |
1317 | throw $this->newBadSelectorException( "Unknown Selector: $id" ); |
1318 | } |
1319 | return $this->selectors1[ $id ]( self::unquote( $cap[ 3 ] ), $this ); |
1320 | } else { |
1321 | if ( !isset( $this->selectors0[ $id ] ) ) { |
1322 | throw $this->newBadSelectorException( "Unknown Selector: $id" ); |
1323 | } |
1324 | return $this->selectors0[ $id ]; |
1325 | } |
1326 | } |
1327 | |
1328 | // attr name |
1329 | // attr op |
1330 | // attr value |
1331 | if ( $cap[ 4 ] ) { |
1332 | $value = $cap[ 6 ] ?? ''; |
1333 | $i = preg_match( "/[\"'\\s]\\s*I\$/i", $value ); |
1334 | if ( $i ) { |
1335 | $value = preg_replace( '/\s*I$/i', '', $value, 1 ); |
1336 | } |
1337 | return $this->selectorsAttr( self::decodeid( $cap[ 4 ] ), $cap[ 5 ] ?? '-', self::unquote( $value ), (bool)$i ); |
1338 | } |
1339 | |
1340 | throw $this->newBadSelectorException( 'Unknown Selector.' ); |
1341 | } |
1342 | |
1343 | /** |
1344 | * Returns true if all $func return true |
1345 | * @param array<callable(DOMNode,array):bool> $func |
1346 | * @return callable(DOMNode,array):bool |
1347 | */ |
1348 | private static function makeSimple( array $func ): callable { |
1349 | $l = count( $func ); |
1350 | |
1351 | // Potentially make sure |
1352 | // `el` is truthy. |
1353 | if ( $l < 2 ) { |
1354 | return $func[ 0 ]; |
1355 | } |
1356 | |
1357 | return static function ( $el, $opts ) use ( $l, $func ): bool { |
1358 | for ( $i = 0; $i < $l; $i++ ) { |
1359 | if ( !call_user_func( $func[ $i ], $el, $opts ) ) { |
1360 | return false; |
1361 | } |
1362 | } |
1363 | return true; |
1364 | }; |
1365 | } |
1366 | |
1367 | /** |
1368 | * Returns the element that all $func return |
1369 | * @param array<callable(DOMNode,array):(?DOMNode)> $func |
1370 | * @return ZestFunc |
1371 | */ |
1372 | private static function makeTest( array $func ): ZestFunc { |
1373 | if ( count( $func ) < 2 ) { |
1374 | return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool { |
1375 | return (bool)call_user_func( $func[ 0 ], $el, $opts ); |
1376 | } ); |
1377 | } |
1378 | return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool { |
1379 | $i = count( $func ); |
1380 | while ( $i-- ) { |
1381 | if ( !( $el = call_user_func( $func[ $i ], $el, $opts ) ) ) { |
1382 | return false; |
1383 | } |
1384 | } |
1385 | return true; |
1386 | } ); |
1387 | } |
1388 | |
1389 | /** |
1390 | * Return a skeleton ZestFunc for the caller to fill in. |
1391 | * @return ZestFunc |
1392 | */ |
1393 | private function makeSubject(): ZestFunc { |
1394 | $target = null; |
1395 | |
1396 | $subject = new ZestFunc( function ( $el, $opts ) use ( &$subject, &$target ): bool { |
1397 | $node = $el->ownerDocument; |
1398 | $scope = $this->getElementsByTagName( $node, $subject->lname, $opts ); |
1399 | $i = count( $scope ); |
1400 | |
1401 | while ( $i-- ) { |
1402 | if ( call_user_func( $subject->test->func, $scope[$i], $opts ) && $target === $el ) { |
1403 | $target = null; |
1404 | return true; |
1405 | } |
1406 | } |
1407 | |
1408 | $target = null; |
1409 | return false; |
1410 | } ); |
1411 | |
1412 | $subject->simple = static function ( $el, $opts ) use ( &$target ): bool { |
1413 | $target = $el; |
1414 | return true; |
1415 | }; |
1416 | |
1417 | return $subject; |
1418 | } |
1419 | |
1420 | /** |
1421 | * @return callable(DOMNode,array):bool |
1422 | */ |
1423 | private function compileGroup( string $sel ): callable { |
1424 | $test = $this->compile( $sel ); |
1425 | $tests = [ $test ]; |
1426 | |
1427 | while ( $test->sel ) { |
1428 | $test = $this->compile( $test->sel ); |
1429 | $tests[] = $test; |
1430 | } |
1431 | |
1432 | if ( count( $tests ) < 2 ) { |
1433 | return $test->func; |
1434 | } |
1435 | |
1436 | return static function ( $el, $opts ) use ( $tests ): bool { |
1437 | for ( $i = 0, $l = count( $tests ); $i < $l; $i++ ) { |
1438 | if ( call_user_func( $tests[ $i ]->func, $el, $opts ) ) { |
1439 | return true; |
1440 | } |
1441 | } |
1442 | return false; |
1443 | }; |
1444 | } |
1445 | |
1446 | /** |
1447 | * Selection |
1448 | */ |
1449 | |
1450 | // $node should be a DOMDocument, DOMDocumentFragment, or a DOMElement |
1451 | // These are "ParentNode" in the DOM spec. |
1452 | |
1453 | /** |
1454 | * @param string $sel |
1455 | * @param DOMDocument|DOMDocumentFragment|DOMElement $node |
1456 | * @param array $opts |
1457 | * @return DOMElement[] |
1458 | */ |
1459 | private function findInternal( string $sel, $node, $opts ): array { |
1460 | $results = []; |
1461 | $test = $this->compile( $sel ); |
1462 | $scope = $this->getElementsByTagName( $node, $test->qname, $opts ); |
1463 | $i = 0; |
1464 | $el = null; |
1465 | $needsSort = false; |
1466 | |
1467 | foreach ( $scope as $el ) { |
1468 | if ( call_user_func( $test->func, $el, $opts ) ) { |
1469 | $results[spl_object_id( $el )] = $el; |
1470 | } |
1471 | } |
1472 | |
1473 | if ( $test->sel ) { |
1474 | $needsSort = true; |
1475 | while ( $test->sel ) { |
1476 | $test = $this->compile( $test->sel ); |
1477 | $scope = $this->getElementsByTagName( $node, $test->qname, $opts ); |
1478 | foreach ( $scope as $el ) { |
1479 | if ( call_user_func( $test->func, $el, $opts ) ) { |
1480 | $results[spl_object_id( $el )] = $el; |
1481 | } |
1482 | } |
1483 | } |
1484 | } |
1485 | |
1486 | $results = array_values( $results ); |
1487 | if ( $needsSort ) { |
1488 | self::sort( $results, $this->isStandardsMode( $node, $opts ) ); |
1489 | } |
1490 | return $results; |
1491 | } |
1492 | |
1493 | /** |
1494 | * Find elements matching a CSS selector underneath $context. |
1495 | * |
1496 | * This search is exclusive; that is, `find(':scope', ...)` returns |
1497 | * no matches, although `:scope *` would return matches. |
1498 | * |
1499 | * @param string $sel The CSS selector string |
1500 | * @param DOMDocument|DOMDocumentFragment|DOMElement $context |
1501 | * The scoping root for the search |
1502 | * @param array $opts Additional match-context options (optional) |
1503 | * @return DOMElement[] Elements matching the CSS selector |
1504 | */ |
1505 | public function find( string $sel, $context, array $opts = [] ): array { |
1506 | $opts['this'] = $this; |
1507 | $opts['scope'] = $context; |
1508 | |
1509 | /* when context isn't a DocumentFragment and the selector is simple: */ |
1510 | if ( $context->nodeType !== 11 && strpos( $sel, ' ' ) === false ) { |
1511 | // https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier |
1512 | // Valid identifiers starting with a hyphen or with escape |
1513 | // sequences will be handled correctly by the fall-through case. |
1514 | if ( $sel[ 0 ] === '#' && preg_match( '/^#[A-Za-z_](?:[-A-Za-z0-9_]|[^\0-\237])*$/Su', $sel ) ) { |
1515 | // Setting 'getElementsById' to `true` disables this |
1516 | // optimization and forces the hard/slow search in |
1517 | // order to guarantee that multiple elements will be |
1518 | // returned if there are multiple elements in the |
1519 | // $context with the given id. |
1520 | if ( ( $opts['getElementsById'] ?? null ) !== true ) { |
1521 | $id = substr( $sel, 1 ); |
1522 | return $this->getElementsById( $context, $id, $opts ); |
1523 | } |
1524 | } |
1525 | if ( $sel[ 0 ] === '.' && preg_match( '/^\.\w+$/', $sel ) ) { |
1526 | return $this->getElementsByClassName( $context, substr( $sel, 1 ), $opts ); |
1527 | } |
1528 | if ( preg_match( '/^\w+$/', $sel ) ) { |
1529 | return $this->getElementsByTagName( $context, $sel, $opts ); |
1530 | } |
1531 | } |
1532 | /* do things the hard/slow way */ |
1533 | return $this->findInternal( $sel, $context, $opts ); |
1534 | } |
1535 | |
1536 | /** |
1537 | * Determine whether an element matches the given selector. |
1538 | * |
1539 | * This test is inclusive; that is, `matches($el, ':scope')` |
1540 | * returns true. |
1541 | * |
1542 | * @param DOMNode $el The element to be tested |
1543 | * @param string $sel The CSS selector string |
1544 | * @param array $opts Additional match-context options (optional) |
1545 | * @return bool True iff the element matches the selector |
1546 | */ |
1547 | public function matches( $el, string $sel, array $opts = [] ): bool { |
1548 | $opts['this'] = $this; |
1549 | $opts['scope'] = $el; |
1550 | |
1551 | $test = new ZestFunc( static function ( $el, $opts ): bool { |
1552 | return true; |
1553 | } ); |
1554 | $test->sel = $sel; |
1555 | do { |
1556 | $test = $this->compile( $test->sel ); |
1557 | if ( call_user_func( $test->func, $el, $opts ) ) { |
1558 | return true; |
1559 | } |
1560 | } while ( $test->sel ); |
1561 | return false; |
1562 | } |
1563 | |
1564 | /** |
1565 | * Allow customization of the exception thrown for a bad selector. |
1566 | * @param string $msg Description of the failure |
1567 | * @return Throwable |
1568 | */ |
1569 | protected function newBadSelectorException( string $msg ): Throwable { |
1570 | return new InvalidArgumentException( $msg ); |
1571 | } |
1572 | |
1573 | /** |
1574 | * Allow subclasses to force Zest into "standards mode" (or not). |
1575 | * The default implementation looks for a 'standardsMode' key in the |
1576 | * option and if that is not present switches to standards mode if |
1577 | * the ownerDocument of the given node is not a \DOMDocument. |
1578 | * @param DOMNode $context a context node |
1579 | * @param array $opts The zest options array pased to ::find, ::matches, etc |
1580 | * @return bool True for standards mode, otherwise false. |
1581 | */ |
1582 | protected function isStandardsMode( $context, array $opts ): bool { |
1583 | // The $opts array can force a specific mode, if key is present |
1584 | if ( array_key_exists( 'standardsMode', $opts ) ) { |
1585 | return (bool)$opts['standardsMode']; |
1586 | } |
1587 | // Otherwise guess "not standard mode" if the node document is a |
1588 | // \DOMDocument, otherwise use standards mode. |
1589 | $doc = self::nodeIsDocument( $context ) ? |
1590 | $context : $context->ownerDocument; |
1591 | return !( $doc instanceof DOMDocument ); |
1592 | } |
1593 | |
1594 | /** @var ?ZestInst */ |
1595 | private static $singleton = null; |
1596 | |
1597 | /** |
1598 | * Create a new instance of Zest. Custom combinators and selectors |
1599 | * registered for each instance of Zest do not bleed |
1600 | * over into other instances. |
1601 | */ |
1602 | public function __construct() { |
1603 | $z = self::$singleton; |
1604 | $this->selectors0 = $z ? $z->selectors0 : []; |
1605 | $this->selectors1 = $z ? $z->selectors1 : []; |
1606 | $this->operators = $z ? $z->operators : []; |
1607 | $this->combinators = $z ? $z->combinators : []; |
1608 | if ( !$z ) { |
1609 | $this->initRules(); |
1610 | $this->initSelectors(); |
1611 | $this->initOperators(); |
1612 | $this->initCombinators(); |
1613 | self::$singleton = $this; |
1614 | // Now create another instance so that backing arrays are cloned |
1615 | // @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams |
1616 | self::$singleton = new ZestInst; |
1617 | } |
1618 | } |
1619 | } |