Page MenuHomeWickedGov Phorge

ZestInst.php
No OneTemporary

Size
50 KB
Referenced Files
None
Subscribers
None

ZestInst.php

<?php
namespace Wikimedia\Zest;
use DOMDocument;
use DOMDocumentFragment;
use DOMElement;
use DOMNode;
use InvalidArgumentException;
use Throwable;
/**
* Zest.php (https://github.com/wikimedia/zest.php)
* Copyright (c) 2019, C. Scott Ananian. (MIT licensed)
* PHP port based on:
*
* Zest (https://github.com/chjj/zest)
* A css selector engine.
* Copyright (c) 2011-2012, Christopher Jeffrey. (MIT Licensed)
* Domino version based on Zest v0.1.3 with bugfixes applied.
*/
class ZestInst {
/** @var ZestFunc[] */
private $compileCache = [];
/**
* Helpers
*/
/**
* Sort query results in document order.
* @param array &$results
* @param bool $isStandardsMode
*/
private static function sort( &$results, bool $isStandardsMode ): void {
if ( count( $results ) < 2 ) {
return;
}
if ( $isStandardsMode ) {
// DOM spec-compliant version:
usort( $results, static function ( $a, $b ) {
return ( $a->compareDocumentPosition( $b ) & 2 ) ? 1 : -1;
} );
}
// PHP's dom extension returns true for method_exists on
// compareDocumentPosition, but when called it throws a
// "Not yet implemented" exception
// If compareDocumentPosition isn't implemented, skip the sort.
// Our results are generally added as a result of an in-order
// traversal of the tree, so absent any funny business with complex
// selectors, our natural order should be more-or-less sorted.
}
/**
* @param DOMNode $el
* @return ?DOMNode
*/
private static function next( $el ) {
while ( ( $el = $el->nextSibling ) && $el->nodeType !== 1 ) {
// no op
}
return $el;
}
/**
* @param DOMNode $el
* @return ?DOMNode
*/
private static function prev( $el ) {
while ( ( $el = $el->previousSibling ) && $el->nodeType !== 1 ) {
// no op
}
return $el;
}
/**
* @param DOMNode $el
* @return ?DOMNode
*/
private static function child( $el ) {
if ( $el = $el->firstChild ) {
while ( $el->nodeType !== 1 && ( $el = $el->nextSibling ) ) {
// no op
}
}
return $el;
}
/**
* @param DOMNode $el
* @return ?DOMNode
*/
private static function lastChild( $el ) {
if ( $el = $el->lastChild ) {
while ( $el->nodeType !== 1 && ( $el = $el->previousSibling ) ) {
// no op
}
}
return $el;
}
/**
* @param DOMNode $n
* @return bool
*/
private static function parentIsElement( $n ): bool {
$parent = $n->parentNode;
if ( !$parent ) {
return false;
}
// The root `html` element (node type 9) can be a first- or
// last-child, too, which means that the document (or document
// fragment) counts as an "element".
return $parent->nodeType === 1 /* Element */ ||
self::nodeIsDocument( $parent ) /* Document */ ||
$parent->nodeType === 11; /* DocumentFragment */
}
/**
* @param DOMNode $n
* @return bool
*/
private static function nodeIsDocument( $n ): bool {
$nodeType = $n->nodeType;
return $nodeType === 9 /* Document */ ||
// In PHP, if you load a document with
// DOMDocument::loadHTML, your root DOMDocument will have node
// type 13 (!) which is PHP's bespoke "XML_HTML_DOCUMENT_NODE"
// and Not A Real Thing. But we'll recognize it anyway...
$nodeType === 13; /* HTMLDocument */
}
private static function unichr( int $codepoint ): string {
if ( extension_loaded( 'intl' ) ) {
return \IntlChar::chr( $codepoint );
} else {
return mb_chr( $codepoint, "utf-8" );
}
}
private static function unquote( string $str ): string {
if ( !$str ) {
return $str;
}
self::initRules();
$ch = $str[ 0 ];
if ( $ch === '"' || $ch === "'" ) {
if ( substr( $str, -1 ) === $ch ) {
$str = substr( $str, 1, -1 );
} else {
// bad string.
$str = substr( $str, 1 );
}
return preg_replace_callback( self::$rules->str_escape, function ( array $matches ) {
$s = $matches[0];
if ( !preg_match( '/^\\\(?:([0-9A-Fa-f]+)|([\r\n\f]+))/', $s, $m ) ) {
return substr( $s, 1 );
}
if ( $m[ 2 ] ) {
return ''; /* escaped newlines are ignored in strings. */
}
$cp = intval( $m[ 1 ], 16 );
return self::unichr( $cp );
}, $str );
} elseif ( preg_match( self::$rules->ident, $str ) ) {
return self::decodeid( $str );
} else {
// NUMBER, PERCENTAGE, DIMENSION, etc
return $str;
}
}
private static function decodeid( string $str ): string {
return preg_replace_callback( self::$rules->escape, function ( array $matches ) {
$s = $matches[0];
if ( !preg_match( '/^\\\([0-9A-Fa-f]+)/', $s, $m ) ) {
return $s[ 1 ];
}
$cp = intval( $m[ 1 ], 16 );
return self::unichr( $cp );
}, $str );
}
/**
* Escape an identifier for CSS.
* This is equivalent to CSS.escape
* (https://drafts.csswg.org/cssom/#the-css.escape()-method)
* and is the opposite of self::decodeid().
*/
private static function encodeid( string $str ): string {
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 ) {
if ( isset( $matches[1] ) ) {
return "\u{FFFD}";
} elseif ( isset( $matches[2] ) || isset( $matches[3] ) ) {
$cp = mb_ord( $matches[0], "UTF-8" );
return '\\' . dechex( $cp ) . ' ';
} elseif ( isset( $matches[4] ) ) {
$cp = mb_ord( $matches[0][1], "UTF-8" );
return '-\\' . dechex( $cp ) . ' ';
} else {
return '\\' . $matches[0];
}
}, $str, -1, $ignore, PREG_UNMATCHED_AS_NULL );
}
private static function makeInside( string $start, string $end ): string {
$regex = preg_replace(
'/>/', $end, preg_replace(
'/</', $start, self::reSource( self::$rules->inside )
)
);
return '/' . $regex . '/Su';
}
private static function reSource( string $regex ): string {
// strip delimiter and flags from regular expression
return preg_replace( '/(^\/)|(\/[a-z]*$)/Diu', '', $regex );
}
private static function replace( string $regex, string $name, string $val ): string {
$regex = self::reSource( $regex );
$regex = str_replace( $name, self::reSource( $val ), $regex );
return '/' . $regex . '/Su';
}
private static function truncateUrl( string $url, int $num ): string {
$url = preg_replace( '/^(?:\w+:\/\/|\/+)/', '', $url );
$url = preg_replace( '/(?:\/+|\/*#.*?)$/', '', $url );
return implode( '/', explode( '/', $url, $num ) );
}
private static function xpathQuote( string $s ): string {
// Ugly-but-functional escape mechanism for xpath query
$parts = explode( "'", $s );
$parts = array_map( static function ( string $ss ) {
return "'$ss'";
}, $parts );
if ( count( $parts ) === 1 ) {
return $parts[0];
} else {
return 'concat(' . implode( ',"\'",', $parts ) . ')';
}
}
/**
* Get descendants by ID.
*
* The PHP DOM doesn't provide this method for DOMElement, and the
* implementation in DOMDocument is broken.
*
* Further, the web spec only provides for returning a single element
* here. This function can support returning *all* of the matches for
* a given ID, if the underlying DOM implementation supports this.
*
* This is an *exclusive* query; that is, $context should never be included
* among the results.
*
* Although a `getElementsById` key can be passed in the options array
* to override the default implementation, for efficiency it is recommended
* that clients subclass ZestInst and override this entire method if
* they can provide an efficient id index.
*
* @param DOMDocument|DOMDocumentFragment|DOMElement $context
* The scoping root for the search
* @param string $id
* @param array $opts Additional match-context options (optional)
* @return array<DOMElement> A list of the elements with the given ID. When there are more
* than one, this method might return all of them or only the first one.
*/
public function getElementsById( $context, string $id, array $opts = [] ): array {
if ( is_callable( $opts['getElementsById'] ?? null ) ) {
// Third-party DOM implementation might provide a way to
// get multiple results for a given ID.
// Note that this must work for DocumentFragment and Element
// as well!
$func = $opts['getElementsById'];
return $func( $context, $id );
}
// Neither PHP nor the web standards provide an DOMElement-scoped
// version of getElementById, so we can't call this directly on
// $context -- but that's okay because (1) IDs should be unique, and
// (2) we verify the scope of the returned element below
// anyway (to work around bugs with deleted-but-not-gc'ed
// nodes).
$doc = self::nodeIsDocument( $context ) ?
$context : $context->ownerDocument;
$r = $doc->getElementById( $id );
// Note that $r could be null here because the
// DOMDocument hasn't had an "id attribute" set, even if the id
// exists in the document. See:
// http://php.net/manual/en/domdocument.getelementbyid.php
if ( $r !== null ) {
// Verify that this node is actually connected to the
// document (or to the context), since the element
// isn't removed from the index immediately when it
// is deleted. (Also PHP's call is not scoped.)
// (Note that scoped getElementsById is *exclusive* of $context,
// so we start this search at r's parent node.)
for ( $parent = $r->parentNode; $parent; $parent = $parent->parentNode ) {
if ( $parent === $context ) {
return [ $r ];
}
}
// It's possible a deleted-but-still-indexed element was
// shadowing a later-added element, so we can't return
// null here directly; fallback to a full search.
}
if ( $this->isStandardsMode( $context, $opts ) ) {
// The workaround below only works (and is only necessary!)
// when this is a PHP-provided \DOMDocument. For 3rd-party
// DOM implementations, we assume that getElementById() was
// reliable.
// @phan-suppress-next-line PhanUndeclaredProperty
if ( $context->isConnected || $id === '' ) {
return [];
}
// For disconnected Elements and DocumentFragments, we need
// to do this the hard/slow way
$r = [];
foreach ( $this->getElementsByTagName( $context, '*', $opts ) as $el ) {
// Work around Phan 8.1 / 7.4 issues by telling Phan what
// the expected type is. Zest could be used with newer DOM
// implementations that could return null for missing attributes.
// See https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute#non-existing_attributes
// But, Phan doesn't know that and only sees the native PHP
// implementation that returns '' for missing attributes.
$elId = $el->getAttribute( 'id' );
'@phan-var ?string $elId';
if ( $id === ( $elId ?? '' ) ) {
$r[] = $el;
}
}
return $r;
}
// Do an xpath search, which is still a full traversal of the tree
// (sigh) but 25% faster than traversing it wholly in PHP.
$xpath = new \DOMXPath( $doc );
$query = './/*[@id=' . self::xpathQuote( $id ) . ']';
if ( $context->nodeType === 11 ) {
// ugh, PHP dom extension workaround: nodes which are direct
// children of the DocumentFragment are not matched unless we
// use a ./ query in addition to the .// query.
$query = "./" . substr( $query, 3 ) . "|$query";
}
return iterator_to_array( $xpath->query( $query, $context ) );
}
private function docFragHelper( $docFrag, string $sel, array $opts, callable $collectFunc ) {
$result = [];
for ( $n = $docFrag->firstChild; $n; $n = $n->nextSibling ) {
if ( $n->nodeType !== 1 ) {
continue; // Not an element
}
// See if $n itself should be included
if ( $this->matches( $n, $sel, $opts ) ) {
$result[] = $n;
}
// Now include all of $n's children
array_splice( $result, count( $result ), 0, $collectFunc( $n ) );
}
return $result;
}
/**
* Get descendants by tag name.
* The PHP DOM doesn't provide this method for DOMElement, and the
* implementation in DOMDocument has performance issues.
*
* This is an *exclusive* query; that is, $context should never be included
* among the results.
*
* Clients can subclass and override this to provide a more efficient
* implementation if one is available.
*
* @param DOMDocument|DOMDocumentFragment|DOMElement $context
* @param string $tagName
* @param array $opts Additional match-context options (optional)
* @return array<DOMElement>
*/
public function getElementsByTagName( $context, string $tagName, array $opts = [] ) {
if ( $context->nodeType === 11 /* DocumentFragment */ ) {
// DOM standards don't define getElementsByTagName on
// DocumentFragment, and XPath supports it but has a bug which
// omits root elements. So emulate in both these cases.
$selector = $tagName === '*' ? '*' : self::encodeid( $tagName );
return $this->docFragHelper(
$context, $selector, $opts,
function ( $el ) use ( $tagName, $opts ): array {
return $this->getElementsByTagName( $el, $tagName, $opts );
}
);
}
if ( $this->isStandardsMode( $context, $opts ) ) {
// For third-party DOM implementations, just use native func.
return iterator_to_array(
$context->getElementsByTagName( $tagName )
);
}
// This *should* just be a call to PHP's `getElementByTagName`
// function *BUT* PHP's implementation is 100x slower than using
// XPath to get the same results (!)
// XXX this assumes default PHP DOM implementation, which
// reports lowercase tag names in DOMNode->tagName (even though
// the DOM spec says it should report uppercase)
$tagName = strtolower( $tagName );
$doc = self::nodeIsDocument( $context ) ?
$context : $context->ownerDocument;
$xpath = new \DOMXPath( $doc );
$ns = $doc->documentElement === null ? 'force use of local-name' :
$doc->documentElement->namespaceURI;
if ( $tagName === '*' ) {
$query = ".//*";
} elseif ( $ns || !preg_match( '/^[_a-z][-.0-9_a-z]*$/S', $tagName ) ) {
$query = './/*[local-name()=' . self::xpathQuote( $tagName ) . ']';
} else {
$query = ".//$tagName";
}
return iterator_to_array( $xpath->query( $query, $context ) );
}
/**
* Clients can subclass and override this to provide a more efficient
* implementation if one is available.
*
* This is an *exclusive* query; that is, $context should never be included
* among the results.
*
* @param DOMDocument|DOMDocumentFragment|DOMElement $context
* @param string $className
* @param array $opts
* @return array<DOMElement>
*/
protected function getElementsByClassName( $context, string $className, $opts ) {
if ( $context->nodeType === 11 /* DocumentFragment */ ) {
// DOM standards don't define getElementsByClassName on
// DocumentFragment, and XPath supports it but has a bug which
// omits root elements. So emulate in both these cases.
return $this->docFragHelper(
$context,
// NOTE this only works when $className is a single class,
// but that's the only way we invoke it.
"." . self::encodeid( $className ),
$opts,
function ( $el ) use ( $className, $opts ): array {
return $this->getElementsByClassName( $el, $className, $opts );
}
);
}
if ( $this->isStandardsMode( $context, $opts ) ) {
// For third-party DOM implementations, just use native func.
return iterator_to_array(
// @phan-suppress-next-line PhanUndeclaredMethod
$context->getElementsByClassName( $className )
);
}
// PHP doesn't have an implementation of this method; use XPath
// to quickly get results. (It would be faster still if there was an
// actual index, but this will be about 25% faster than doing the
// tree traversal all in PHP.)
$doc = self::nodeIsDocument( $context ) ?
$context : $context->ownerDocument;
$xpath = new \DOMXPath( $doc );
$quotedClassName = self::xpathQuote( " $className " );
$query = ".//*[contains(concat(' ', normalize-space(@class), ' '), $quotedClassName)]";
return iterator_to_array( $xpath->query( $query, $context ) );
}
/**
* Handle `nth` Selectors
*/
private static function parseNth( string $param ): object {
$param = preg_replace( '/\s+/', '', $param );
if ( $param === 'even' ) {
$param = '2n+0';
} elseif ( $param === 'odd' ) {
$param = '2n+1';
} elseif ( strpos( $param, 'n' ) === false ) {
$param = '0n' . $param;
}
preg_match( '/^([+-])?(\d+)?n([+-])?(\d+)?$/', $param, $cap, PREG_UNMATCHED_AS_NULL );
$group = intval( ( $cap[1] ?? '' ) . ( $cap[2] ?? '1' ), 10 );
$offset = intval( ( $cap[3] ?? '' ) . ( $cap[4] ?? '0' ), 10 );
return (object)[
'group' => $group,
'offset' => $offset,
];
}
/**
* @param string $param
* @param callable(DOMNode,DOMNode,array):bool $test
* @param bool $last
* @return callable(DOMNode,array):bool
*/
private static function nth( string $param, callable $test, bool $last ): callable {
$param = self::parseNth( $param );
$group = $param->group;
$offset = $param->offset;
$find = ( !$last ) ? [ self::class, 'child' ] : [ self::class, 'lastChild' ];
$advance = ( !$last ) ? [ self::class, 'next' ] : [ self::class, 'prev' ];
return function ( $el, array $opts ) use ( $find, $test, $offset, $group, $advance ): bool {
if ( !self::parentIsElement( $el ) ) {
return false;
}
$rel = call_user_func( $find, $el->parentNode );
$pos = 0;
while ( $rel ) {
if ( call_user_func( $test, $rel, $el, $opts ) ) {
$pos++;
}
if ( $rel === $el ) {
$pos -= $offset;
return ( $group && $pos )
? ( $pos % $group ) === 0 && ( ( $pos < 0 ) === ( $group < 0 ) )
: !$pos;
}
$rel = call_user_func( $advance, $rel );
}
return false;
};
}
/**
* Simple Selectors which take no arguments.
* @var array<string,(callable(DOMNode,array):bool)>
*/
private $selectors0;
/**
* Simple Selectors which take one argument.
* @var array<string,(callable(string,ZestInst):(callable(DOMNode,array):bool))>
*/
private $selectors1;
/**
* Add a custom selector that takes no parameters.
* @param string $key Name of the selector
* @param callable(DOMNode,array):bool $func
* The selector match function
*/
public function addSelector0( string $key, callable $func ) {
$this->selectors0[$key] = $func;
}
/**
* Add a custom selector that takes 1 parameter, which is passed as a
* string.
* @param string $key Name of the selector
* @param callable(string,ZestInst):(callable(DOMNode,array):bool)|callable(string):(callable(DOMNode,array):bool) $func
* The selector match function
*/
public function addSelector1( string $key, callable $func ) {
$this->selectors1[$key] = $func;
}
private function initSelectors() {
// Careful: this method is only called once, on the singleton
// ZestInst, which all child ZestInst instances inherit their
// default selector lists from. But as a result $this is
// always the singleton; be sure to use the $self argument (for
// selector1) or $opts['this'] (for selector0) to access the
// dynamically-bound $this.
$this->addSelector0( '*', static function ( $el, $opts ): bool {
return true;
} );
$this->addSelector1( 'type', static function ( string $type ): callable {
$type = strtolower( $type );
return static function ( $el, $opts ) use ( $type ): bool {
return strtolower( $el->nodeName ) === $type;
};
} );
$this->addSelector1( 'typeNoNS', static function ( string $type ): callable {
$type = strtolower( $type );
return static function ( $el, $opts ) use ( $type ): bool {
return ( $el->namespaceURI ?? '' ) === '' &&
strtolower( $el->nodeName ) === $type;
};
} );
$this->addSelector0( ':first-child', function ( $el, $opts ): bool {
return !self::prev( $el ) && self::parentIsElement( $el );
} );
$this->addSelector0( ':last-child', function ( $el, $opts ): bool {
return !self::next( $el ) && self::parentIsElement( $el );
} );
$this->addSelector0( ':only-child', function ( $el, $opts ): bool {
return !self::prev( $el ) && !self::next( $el )
&& self::parentIsElement( $el );
} );
$this->addSelector1( ':nth-child', function ( string $param ): callable {
return self::nth( $param, static function ( $rel, $el, $opts ): bool {
return true;
}, false /* last */ );
} );
$this->addSelector1( ':nth-last-child', function ( string $param ): callable {
return self::nth( $param, static function ( $rel, $el, $opts ): bool {
return true;
}, true /* last */ );
} );
$this->addSelector0( ':root', static function ( $el, $opts ): bool {
return $el->ownerDocument->documentElement === $el;
} );
$this->addSelector0( ':empty', static function ( $el, $opts ): bool {
return !$el->firstChild;
} );
$this->addSelector1( ':not', static function ( string $sel, ZestInst $self ) {
$test = $self->compileGroup( $sel );
return static function ( $el, $opts ) use ( $test ): bool {
return !call_user_func( $test, $el, $opts );
};
} );
$this->addSelector0( ':first-of-type', function ( $el, $opts ): bool {
if ( !self::parentIsElement( $el ) ) {
return false;
}
$type = $el->nodeName;
while ( $el = self::prev( $el ) ) {
if ( $el->nodeName === $type ) {
return false;
}
}
return true;
} );
$this->addSelector0( ':last-of-type', function ( $el, $opts ): bool {
if ( !self::parentIsElement( $el ) ) {
return false;
}
$type = $el->nodeName;
while ( $el = self::next( $el ) ) {
if ( $el->nodeName === $type ) {
return false;
}
}
return true;
} );
$this->addSelector0( ':only-of-type', static function ( $el, $opts ): bool {
$self = $opts['this'];
return $self->selectors0[ ':first-of-type' ]( $el, $opts ) &&
$self->selectors0[ ':last-of-type' ]( $el, $opts );
} );
$makeNthOfType = static function ( bool $last ) {
return function ( string $param ) use ( $last ): callable {
return self::nth( $param, static function ( $rel, $el, $opts ): bool {
return $rel->nodeName === $el->nodeName;
}, $last );
};
};
$this->addSelector1( ':nth-of-type', $makeNthOfType( false ) );
$this->addSelector1( ':nth-last-of-type', $makeNthOfType( true ) );
/** @suppress PhanUndeclaredProperty not defined in PHP DOM */
$this->addSelector0( ':checked', static function ( $el, $opts ): bool {
'@phan-var DOMElement $el';
$self = $opts['this'];
if ( $self->isStandardsMode( $el, $opts ) ) {
// These properties don't exist in the PHP DOM, and in fact
// they are supposed to reflect the *dynamic* state of the
// widget, not the 'default' state (which is given by the
// attribute value)
if ( isset( $el->checked ) || isset( $el->selected ) ) {
return ( isset( $el->checked ) && $el->checked ) ||
( isset( $el->selected ) && $el->selected );
}
}
return $el->hasAttribute( 'checked' ) || $el->hasAttribute( 'selected' );
} );
$this->addSelector0( ':indeterminate', static function ( $el, $opts ): bool {
$self = $opts['this'];
return !$self->selectors0[ ':checked' ]( $el, $opts );
} );
/** @suppress PhanUndeclaredProperty not defined in PHP DOM */
$this->addSelector0( ':enabled', static function ( $el, $opts ): bool {
'@phan-var DOMElement $el';
$self = $opts['this'];
if ( $self->isStandardsMode( $el, $opts ) && isset( $el->type ) ) {
$type = $el->type; // this does case normalization in spec
} else {
$type = $el->getAttribute( 'type' );
}
return !$el->hasAttribute( 'disabled' ) && $type !== 'hidden';
} );
$this->addSelector0( ':disabled', static function ( $el, $opts ): bool {
'@phan-var DOMElement $el';
return $el->hasAttribute( 'disabled' );
} );
/*
$this->addSelector0( ':target', function ( $el ) use ( &$window ) {
return $el->id === $window->location->hash->substring( 1 );
});
$this->addSelector0( ':focus', function ( $el ) {
return $el === $el->ownerDocument->activeElement;
});
*/
$this->addSelector1( ':is', static function ( string $sel, ZestInst $self ): callable {
return $self->compileGroup( $sel );
} );
// :matches is an older name for :is; see
// https://github.com/w3c/csswg-drafts/issues/3258
$this->addSelector1( ':matches', static function ( string $sel, ZestInst $self ): callable {
return $self->selectors1[ ':is' ]( $sel, $self );
} );
$makeNthMatch = static function ( bool $last ) {
return function ( string $param, ZestInst $self ) use ( $last ): callable {
$args = preg_split( '/\s*,\s*/', $param );
$arg = array_shift( $args );
$test = $self->compileGroup( implode( ',', $args ) );
return self::nth( $arg, static function ( $rel, $el, $opts ) use ( $test ): bool {
return call_user_func( $test, $el, $opts );
}, $last );
};
};
$this->addSelector1( ':nth-match', $makeNthMatch( false ) );
$this->addSelector1( ':nth-last-match', $makeNthMatch( true ) );
/*
$this->addSelector0( ':links-here', function ( $el ) use ( &$window ) {
return $el . '' === $window->location . '';
});
*/
$this->addSelector1( ':lang', static function ( string $param ): callable {
return static function ( $el, $opts ) use ( $param ): bool {
while ( $el ) {
if ( $el->nodeType === 1 /* Element */ ) {
'@phan-var DOMElement $el';
// PHP DOM doesn't have 'lang' property
$lang = $el->getAttribute( 'lang' );
if ( $lang ) {
return strpos( $lang, $param ) === 0;
}
}
$el = $el->parentNode;
}
return false;
};
} );
$this->addSelector1( ':dir', static function ( string $param ): callable {
return static function ( $el, $opts ) use ( $param ): bool {
while ( $el ) {
if ( $el->nodeType === 1 /* Element */ ) {
'@phan-var DOMElement $el';
$dir = $el->getAttribute( 'dir' );
if ( $dir ) {
return $dir === $param;
}
}
$el = $el->parentNode;
}
return false;
};
} );
$this->addSelector0( ':scope', static function ( $el, $opts ): bool {
$self = $opts['this'];
$scope = $opts['scope'] ?? null;
if ( $scope !== null && $scope->nodeType === 1 ) {
return $el === $scope;
}
// If the scoping root is missing or not an element, then :scope
// should be a synonym for :root
return $self->selectors0[ ':root' ]( $el, $opts );
} );
/*
$this->addSelector0( ':any-link', function ( $el ):bool {
return gettype( $el->href ) === 'string';
});
$this->addSelector( ':local-link', function ( $el ) use ( &$window ) {
if ( $el->nodeName ) {
return $el->href && $el->host === $window->location->host;
}
// XXX this is really selector1 not selector0
$param = +$el + 1;
return function ( $el ) use ( &$window, $param ) {
if ( !$el->href ) { return; }
$url = $window->location . '';
$href = $el . '';
return self::truncateUrl( $url, $param ) === self::truncateUrl( $href, $param );
};
});
$this->addSelector0( ':default', function ( $el ):bool {
return !!$el->defaultSelected;
});
$this->addSelector0( ':valid', function ( $el ):bool {
return $el->willValidate || ( $el->validity && $el->validity->valid );
});
*/
$this->addSelector0( ':invalid', static function ( $el, $opts ): bool {
$self = $opts['this'];
return !$self->selectors0[ ':valid' ]( $el, $opts );
} );
/*
$this->addSelector0( ':in-range', function ( $el ):bool {
return $el->value > $el->min && $el->value <= $el->max;
});
*/
$this->addSelector0( ':out-of-range', static function ( $el, $opts ): bool {
$self = $opts['this'];
return !$self->selectors0[ ':in-range' ]( $el, $opts );
} );
$this->addSelector0( ':required', static function ( $el, $opts ): bool {
'@phan-var DOMElement $el';
return $el->hasAttribute( 'required' );
} );
$this->addSelector0( ':optional', static function ( $el, $opts ): bool {
$self = $opts['this'];
return !$self->selectors0[ ':required' ]( $el, $opts );
} );
$this->addSelector0( ':read-only', static function ( $el, $opts ): bool {
'@phan-var DOMElement $el';
if ( $el->hasAttribute( 'readOnly' ) ) {
return true;
}
$attr = $el->getAttribute( 'contenteditable' );
$name = strtolower( $el->nodeName );
$name = $name !== 'input' && $name !== 'textarea';
return ( $name || $el->hasAttribute( 'disabled' ) ) && $attr == null;
} );
$this->addSelector0( ':read-write', static function ( $el, $opts ): bool {
$self = $opts['this'];
return !$self->selectors0[ ':read-only' ]( $el, $opts );
} );
foreach ( [
':hover',
':active',
':link',
':visited',
':column',
':nth-column',
':nth-last-column',
':current',
':past',
':future',
] as $selector ) {
$this->addSelector0(
$selector,
/**
* @param DOMNode $el
* @param array $opts
* @return never
*/
static function ( $el, $opts ) use ( $selector ): bool {
$self = $opts['this'];
throw $self->newBadSelectorException( $selector . ' is not supported.' );
}
);
}
// Non-standard, for compatibility purposes.
$this->addSelector1( ':contains', static function ( string $param ): callable {
return static function ( $el ) use ( $param ): bool {
$text = $el->textContent;
return strpos( $text, $param ) !== false;
};
} );
$this->addSelector1( ':has', static function ( string $param ): callable {
return static function ( $el, array $opts ) use ( $param ): bool {
'@phan-var DOMElement $el';
$self = $opts['this'];
return count( $self->find( $param, $el, $opts ) ) > 0;
};
} );
// Potentially add more pseudo selectors for
// compatibility with sizzle and most other
// selector engines (?).
}
/** @return callable(DOMNode,array):bool */
private function selectorsAttr( string $key, string $op, string $val, bool $i ): callable {
$op = $this->operators[ $op ];
return static function ( $el, $opts ) use ( $key, $i, $op, $val ): bool {
/* XXX: the below all assumes a more complete PHP DOM than we have
switch ( $key ) {
#case 'for':
# $attr = $el->htmlFor; // Not supported in PHP DOM
# break;
case 'class':
// PHP DOM doesn't support $el->className
// className is '' when non-existent
// getAttribute('class') is null
if ($el->hasAttributes() && $el->hasAttribute( 'class' ) ) {
$attr = $el->getAttribute( 'class' );
} else {
$attr = null;
}
break;
case 'href':
case 'src':
$attr = $el->getAttribute( $key, 2 );
break;
case 'title':
// getAttribute('title') can be '' when non-existent sometimes?
if ($el->hasAttribute('title')) {
$attr = $el->getAttribute( 'title' );
} else {
$attr = null;
}
break;
// careful with attributes with special getter functions
case 'id':
case 'lang':
case 'dir':
case 'accessKey':
case 'hidden':
case 'tabIndex':
case 'style':
if ( $el->getAttribute ) {
$attr = $el->getAttribute( $key );
break;
}
// falls through
default:
if ( $el->hasAttribute && !$el->hasAttribute( $key ) ) {
break;
}
$attr = ( $el[ $key ] != null ) ?
$el[ $key ] :
$el->getAttribute && $el->getAttribute( $key );
break;
}
*/
// This is our simple PHP DOM version
'@phan-var DOMElement $el';
if ( $el->hasAttributes() && $el->hasAttribute( $key ) ) {
$attr = $el->getAttribute( $key );
} else {
return false;
}
// End simple PHP DOM version
if ( $i ) {
$attr = strtolower( $attr );
$val = strtolower( $val );
}
return call_user_func( $op, $attr, $val );
};
}
/**
* Attribute Operators
* @var array<string,(callable(string,string):bool)>
*/
private $operators;
/**
* Add a custom operator
* @param string $key Name of the operator
* @param callable(string,string):bool $func
* The operator match function
*/
public function addOperator( string $key, callable $func ) {
$this->operators[$key] = $func;
}
private function initOperators() {
// Be careful: $this always points to the singleton ZestInst
$this->addOperator( '-', static function ( string $attr, string $val ): bool {
return true;
} );
$this->addOperator( '=', static function ( string $attr, string $val ): bool {
return $attr === $val;
} );
$this->addOperator( '*=', static function ( string $attr, string $val ): bool {
return strpos( $attr, $val ) !== false;
} );
$this->addOperator( '~=', static function ( string $attr, string $val ): bool {
// https://drafts.csswg.org/selectors-4/#attribute-representation
// If "val" contains whitespace, it will never represent
// anything (since the words are separated by spaces)
if ( strcspn( $val, " \t\r\n\f" ) !== strlen( $val ) ) {
return false;
}
// Also if "val" is the empty string, it will never
// represent anything.
if ( strlen( $val ) === 0 ) {
return false;
}
$attrLen = strlen( $attr );
$valLen = strlen( $val );
for ( $s = 0; $s < $attrLen; $s = $i + 1 ) {
$i = strpos( $attr, $val, $s );
if ( $i === false ) {
return false;
}
$j = $i + $valLen;
$f = ( $i === 0 ) ? ' ' : $attr[ $i - 1 ];
$l = ( $j >= $attrLen ) ? ' ' : $attr[ $j ];
$f = strtr( $f, "\t\r\n\f", " " );
$l = strtr( $l, "\t\r\n\f", " " );
if ( $f === ' ' && $l === ' ' ) {
return true;
}
}
return false;
} );
$this->addOperator( '|=', static function ( string $attr, string $val ): bool {
$i = strpos( $attr, $val );
if ( $i !== 0 ) {
return false;
}
$j = $i + strlen( $val );
if ( $j >= strlen( $attr ) ) {
return true;
}
$l = $attr[ $j ];
return $l === '-';
} );
$this->addOperator( '^=', static function ( string $attr, string $val ): bool {
return strpos( $attr, $val ) === 0;
} );
$this->addOperator( '$=', static function ( string $attr, string $val ): bool {
$i = strrpos( $attr, $val );
return $i !== false && $i + strlen( $val ) === strlen( $attr );
} );
// non-standard
$this->addOperator( '!=', static function ( string $attr, string $val ): bool {
return $attr !== $val;
} );
}
/**
* Combinator Logic
* @var array<string,(callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)))>
*/
private $combinators;
/**
* Add a custom combinator
* @param string $key Name of the combinator
* @param callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)) $func
* The combinator match function
*/
public function addCombinator( string $key, callable $func ) {
$this->combinators[$key] = $func;
}
private function initCombinators() {
// Be careful: $this always points to the singleton ZestInst
$this->addCombinator( ' ', static function ( callable $test ): callable {
return static function ( $el, $opts ) use ( $test ) {
while ( $el = $el->parentNode ) {
if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) {
return $el;
}
}
return null;
};
} );
$this->addCombinator( '>', static function ( callable $test ): callable {
return static function ( $el, $opts ) use ( $test ) {
if ( $el = $el->parentNode ) {
if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) {
return $el;
}
}
return null;
};
} );
$this->addCombinator( '+', function ( callable $test ): callable {
return function ( $el, $opts ) use ( $test ) {
if ( $el = self::prev( $el ) ) {
if ( call_user_func( $test, $el, $opts ) ) {
return $el;
}
}
return null;
};
} );
$this->addCombinator( '~', function ( callable $test ): callable {
return function ( $el, $opts ) use ( $test ) {
while ( $el = self::prev( $el ) ) {
if ( call_user_func( $test, $el, $opts ) ) {
return $el;
}
}
return null;
};
} );
$this->addCombinator( 'noop', static function ( callable $test ): callable {
return static function ( $el, $opts ) use ( $test ) {
if ( call_user_func( $test, $el, $opts ) ) {
return $el;
}
return null;
};
} );
}
/**
* @param callable(DOMNode,array):bool $test
* @param string $name
* @return ZestFunc
*/
private function makeRef( callable $test, string $name ): ZestFunc {
$node = null;
$ref = new ZestFunc( function ( $el, $opts ) use ( &$node, &$ref ): bool {
$doc = $el->ownerDocument;
$nodes = $this->getElementsByTagName( $doc, '*', $opts );
$i = count( $nodes );
while ( $i-- ) {
$node = $nodes[$i];
if ( call_user_func( $ref->test->func, $el, $opts ) ) {
$node = null;
return true;
}
}
$node = null;
return false;
} );
$ref->combinator = static function ( $el, $opts ) use ( &$node, $name, $test ) {
if ( !$node || $node->nodeType !== 1 /* Element */ ) {
return null;
}
$attr = $node->getAttribute( $name ) ?? '';
if ( $attr !== '' && $attr[ 0 ] === '#' ) {
$attr = substr( $attr, 1 );
}
$id = $node->getAttribute( 'id' ) ?? '';
if ( $attr === $id && call_user_func( $test, $node, $opts ) ) {
return $node;
}
return null;
};
return $ref;
}
/**
* Grammar
*/
/** @var \stdClass */
private static $rules;
public static function initRules() {
self::$rules = (object)[
'escape' => '/\\\(?:[^0-9A-Fa-f\r\n]|[0-9A-Fa-f]{1,6}[\r\n\t ]?)/',
'str_escape' => '/(escape)|\\\(\n|\r\n?|\f)/',
'nonascii' => '/[\x{00A0}-\x{FFFF}]/',
'cssid' => '/(?:(?!-?[0-9])(?:escape|nonascii|[-_a-zA-Z0-9])+)/',
'qname' => '/^ *((?:\*?\|)?cssid|\*)/',
'simple' => '/^(?:([.#]cssid)|pseudo|attr)/',
'ref' => '/^ *\/(cssid)\/ */',
'combinator' => '/^(?: +([^ \w*.#\\\]) +|( )+|([^ \w*.#\\\]))(?! *$)/',
'attr' => '/^\[(cssid)(?:([^\w]?=)(inside))?\]/',
'pseudo' => '/^(:cssid)(?:\((inside)\))?/',
'inside' => "/(?:\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'|<[^\"'>]*>|\\\\[\"'>]|[^\"'>])*/",
'ident' => '/^(cssid)$/',
];
self::$rules->cssid = self::replace( self::$rules->cssid, 'nonascii', self::$rules->nonascii );
self::$rules->cssid = self::replace( self::$rules->cssid, 'escape', self::$rules->escape );
self::$rules->qname = self::replace( self::$rules->qname, 'cssid', self::$rules->cssid );
self::$rules->simple = self::replace( self::$rules->simple, 'cssid', self::$rules->cssid );
self::$rules->ref = self::replace( self::$rules->ref, 'cssid', self::$rules->cssid );
self::$rules->attr = self::replace( self::$rules->attr, 'cssid', self::$rules->cssid );
self::$rules->pseudo = self::replace( self::$rules->pseudo, 'cssid', self::$rules->cssid );
self::$rules->inside = self::replace( self::$rules->inside, "[^\"'>]*", self::$rules->inside );
self::$rules->attr = self::replace( self::$rules->attr, 'inside', self::makeInside( '\[', '\]' ) );
self::$rules->pseudo = self::replace( self::$rules->pseudo, 'inside', self::makeInside( '\(', '\)' ) );
self::$rules->simple = self::replace( self::$rules->simple, 'pseudo', self::$rules->pseudo );
self::$rules->simple = self::replace( self::$rules->simple, 'attr', self::$rules->attr );
self::$rules->ident = self::replace( self::$rules->ident, 'cssid', self::$rules->cssid );
self::$rules->str_escape = self::replace( self::$rules->str_escape, 'escape', self::$rules->escape );
}
/**
* Compiling
*/
private function compile( string $sel ): ZestFunc {
if ( !isset( $this->compileCache[$sel] ) ) {
$this->compileCache[$sel] = $this->doCompile( $sel );
}
return $this->compileCache[$sel];
}
private function doCompile( string $sel ): ZestFunc {
$sel = preg_replace( '/^\s+|\s+$/', '', $sel );
$test = null;
$filter = [];
$buff = [];
$subject = null;
$qname = null;
$cap = null;
$op = null;
$ref = null;
while ( $sel ) {
if ( preg_match( self::$rules->qname, $sel, $cap ) ) {
$sel = substr( $sel, strlen( $cap[0] ) );
$qname = self::decodeid( $cap[ 1 ] );
$buff[] = $this->tokQname( $qname );
// strip off *| or | prefix
if ( substr( $qname, 0, 1 ) === '|' ) {
$qname = substr( $qname, 1 );
} elseif ( substr( $qname, 0, 2 ) === '*|' ) {
$qname = substr( $qname, 2 );
}
} elseif ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
$sel = substr( $sel, strlen( $cap[0] ) );
$qname = '*';
$buff[] = $this->tokQname( $qname );
$buff[] = $this->tok( $cap );
} else {
throw $this->newBadSelectorException( 'Invalid selector.' );
}
while ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
$sel = substr( $sel, strlen( $cap[0] ) );
$buff[] = $this->tok( $cap );
}
if ( $sel && $sel[ 0 ] === '!' ) {
$sel = substr( $sel, 1 );
$subject = $this->makeSubject();
$subject->qname = $qname;
$buff[] = $subject->simple;
}
if ( preg_match( self::$rules->ref, $sel, $cap ) ) {
$sel = substr( $sel, strlen( $cap[0] ) );
$ref = $this->makeRef( self::makeSimple( $buff ), self::decodeid( $cap[ 1 ] ) );
$filter[] = $ref->combinator;
$buff = [];
continue;
}
if ( preg_match( self::$rules->combinator, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
$sel = substr( $sel, strlen( $cap[0] ) );
$op = $cap[ 1 ] ?? $cap[ 2 ] ?? $cap[ 3 ];
if ( $op === ',' ) {
$filter[] = $this->combinators['noop']( self::makeSimple( $buff ) );
break;
}
} else {
$op = 'noop';
}
if ( !isset( $this->combinators[ $op ] ) ) {
throw $this->newBadSelectorException( 'Bad combinator: ' . $op );
}
$filter[] = $this->combinators[ $op ]( self::makeSimple( $buff ) );
$buff = [];
}
$test = self::makeTest( $filter );
$test->qname = $qname;
$test->sel = $sel;
if ( $subject ) {
$subject->lname = $test->qname;
$subject->test = $test;
// @phan-suppress-next-line PhanPluginDuplicateExpressionAssignment
$subject->qname = $subject->qname;
$subject->sel = $test->sel;
$test = $subject;
}
if ( $ref ) {
$ref->test = $test;
$ref->qname = $test->qname;
$ref->sel = $test->sel;
$test = $ref;
}
return $test;
}
/** @return callable(DOMNode,array):bool */
private function tokQname( string $cap ): callable {
// qname
if ( $cap === '*' ) {
return $this->selectors0['*'];
} elseif ( substr( $cap, 0, 1 ) === '|' ) {
// no namespace
return $this->selectors1['typeNoNS']( substr( $cap, 1 ), $this );
} elseif ( substr( $cap, 0, 2 ) === '*|' ) {
// any namespace including no namespace
return $this->selectors1['type']( substr( $cap, 2 ), $this );
} else {
return $this->selectors1['type']( $cap, $this );
}
}
/** @return callable(DOMNode,array):bool */
private function tok( array $cap ): callable {
// class/id
if ( $cap[ 1 ] ) {
return $cap[ 1 ][ 0 ] === '.'
// XXX unescape here? or in attr?
? $this->selectorsAttr( 'class', '~=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false ) :
$this->selectorsAttr( 'id', '=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false );
}
// pseudo-name
// inside-pseudo
if ( $cap[ 2 ] ) {
$id = self::decodeid( $cap[ 2 ] );
if ( isset( $cap[3] ) && $cap[ 3 ] ) {
if ( !isset( $this->selectors1[ $id ] ) ) {
throw $this->newBadSelectorException( "Unknown Selector: $id" );
}
return $this->selectors1[ $id ]( self::unquote( $cap[ 3 ] ), $this );
} else {
if ( !isset( $this->selectors0[ $id ] ) ) {
throw $this->newBadSelectorException( "Unknown Selector: $id" );
}
return $this->selectors0[ $id ];
}
}
// attr name
// attr op
// attr value
if ( $cap[ 4 ] ) {
$value = $cap[ 6 ] ?? '';
$i = preg_match( "/[\"'\\s]\\s*I\$/i", $value );
if ( $i ) {
$value = preg_replace( '/\s*I$/i', '', $value, 1 );
}
return $this->selectorsAttr( self::decodeid( $cap[ 4 ] ), $cap[ 5 ] ?? '-', self::unquote( $value ), (bool)$i );
}
throw $this->newBadSelectorException( 'Unknown Selector.' );
}
/**
* Returns true if all $func return true
* @param array<callable(DOMNode,array):bool> $func
* @return callable(DOMNode,array):bool
*/
private static function makeSimple( array $func ): callable {
$l = count( $func );
// Potentially make sure
// `el` is truthy.
if ( $l < 2 ) {
return $func[ 0 ];
}
return static function ( $el, $opts ) use ( $l, $func ): bool {
for ( $i = 0; $i < $l; $i++ ) {
if ( !call_user_func( $func[ $i ], $el, $opts ) ) {
return false;
}
}
return true;
};
}
/**
* Returns the element that all $func return
* @param array<callable(DOMNode,array):(?DOMNode)> $func
* @return ZestFunc
*/
private static function makeTest( array $func ): ZestFunc {
if ( count( $func ) < 2 ) {
return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool {
return (bool)call_user_func( $func[ 0 ], $el, $opts );
} );
}
return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool {
$i = count( $func );
while ( $i-- ) {
if ( !( $el = call_user_func( $func[ $i ], $el, $opts ) ) ) {
return false;
}
}
return true;
} );
}
/**
* Return a skeleton ZestFunc for the caller to fill in.
* @return ZestFunc
*/
private function makeSubject(): ZestFunc {
$target = null;
$subject = new ZestFunc( function ( $el, $opts ) use ( &$subject, &$target ): bool {
$node = $el->ownerDocument;
$scope = $this->getElementsByTagName( $node, $subject->lname, $opts );
$i = count( $scope );
while ( $i-- ) {
if ( call_user_func( $subject->test->func, $scope[$i], $opts ) && $target === $el ) {
$target = null;
return true;
}
}
$target = null;
return false;
} );
$subject->simple = static function ( $el, $opts ) use ( &$target ): bool {
$target = $el;
return true;
};
return $subject;
}
/**
* @return callable(DOMNode,array):bool
*/
private function compileGroup( string $sel ): callable {
$test = $this->compile( $sel );
$tests = [ $test ];
while ( $test->sel ) {
$test = $this->compile( $test->sel );
$tests[] = $test;
}
if ( count( $tests ) < 2 ) {
return $test->func;
}
return static function ( $el, $opts ) use ( $tests ): bool {
for ( $i = 0, $l = count( $tests ); $i < $l; $i++ ) {
if ( call_user_func( $tests[ $i ]->func, $el, $opts ) ) {
return true;
}
}
return false;
};
}
/**
* Selection
*/
// $node should be a DOMDocument, DOMDocumentFragment, or a DOMElement
// These are "ParentNode" in the DOM spec.
/**
* @param string $sel
* @param DOMDocument|DOMDocumentFragment|DOMElement $node
* @param array $opts
* @return DOMElement[]
*/
private function findInternal( string $sel, $node, $opts ): array {
$results = [];
$test = $this->compile( $sel );
$scope = $this->getElementsByTagName( $node, $test->qname, $opts );
$i = 0;
$el = null;
$needsSort = false;
foreach ( $scope as $el ) {
if ( call_user_func( $test->func, $el, $opts ) ) {
$results[spl_object_id( $el )] = $el;
}
}
if ( $test->sel ) {
$needsSort = true;
while ( $test->sel ) {
$test = $this->compile( $test->sel );
$scope = $this->getElementsByTagName( $node, $test->qname, $opts );
foreach ( $scope as $el ) {
if ( call_user_func( $test->func, $el, $opts ) ) {
$results[spl_object_id( $el )] = $el;
}
}
}
}
$results = array_values( $results );
if ( $needsSort ) {
self::sort( $results, $this->isStandardsMode( $node, $opts ) );
}
return $results;
}
/**
* Find elements matching a CSS selector underneath $context.
*
* This search is exclusive; that is, `find(':scope', ...)` returns
* no matches, although `:scope *` would return matches.
*
* @param string $sel The CSS selector string
* @param DOMDocument|DOMDocumentFragment|DOMElement $context
* The scoping root for the search
* @param array $opts Additional match-context options (optional)
* @return DOMElement[] Elements matching the CSS selector
*/
public function find( string $sel, $context, array $opts = [] ): array {
$opts['this'] = $this;
$opts['scope'] = $context;
/* when context isn't a DocumentFragment and the selector is simple: */
if ( $context->nodeType !== 11 && strpos( $sel, ' ' ) === false ) {
// https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
// Valid identifiers starting with a hyphen or with escape
// sequences will be handled correctly by the fall-through case.
if ( $sel[ 0 ] === '#' && preg_match( '/^#[A-Za-z_](?:[-A-Za-z0-9_]|[^\0-\237])*$/Su', $sel ) ) {
// Setting 'getElementsById' to `true` disables this
// optimization and forces the hard/slow search in
// order to guarantee that multiple elements will be
// returned if there are multiple elements in the
// $context with the given id.
if ( ( $opts['getElementsById'] ?? null ) !== true ) {
$id = substr( $sel, 1 );
return $this->getElementsById( $context, $id, $opts );
}
}
if ( $sel[ 0 ] === '.' && preg_match( '/^\.\w+$/', $sel ) ) {
return $this->getElementsByClassName( $context, substr( $sel, 1 ), $opts );
}
if ( preg_match( '/^\w+$/', $sel ) ) {
return $this->getElementsByTagName( $context, $sel, $opts );
}
}
/* do things the hard/slow way */
return $this->findInternal( $sel, $context, $opts );
}
/**
* Determine whether an element matches the given selector.
*
* This test is inclusive; that is, `matches($el, ':scope')`
* returns true.
*
* @param DOMNode $el The element to be tested
* @param string $sel The CSS selector string
* @param array $opts Additional match-context options (optional)
* @return bool True iff the element matches the selector
*/
public function matches( $el, string $sel, array $opts = [] ): bool {
$opts['this'] = $this;
$opts['scope'] = $el;
$test = new ZestFunc( static function ( $el, $opts ): bool {
return true;
} );
$test->sel = $sel;
do {
$test = $this->compile( $test->sel );
if ( call_user_func( $test->func, $el, $opts ) ) {
return true;
}
} while ( $test->sel );
return false;
}
/**
* Allow customization of the exception thrown for a bad selector.
* @param string $msg Description of the failure
* @return Throwable
*/
protected function newBadSelectorException( string $msg ): Throwable {
return new InvalidArgumentException( $msg );
}
/**
* Allow subclasses to force Zest into "standards mode" (or not).
* The default implementation looks for a 'standardsMode' key in the
* option and if that is not present switches to standards mode if
* the ownerDocument of the given node is not a \DOMDocument.
* @param DOMNode $context a context node
* @param array $opts The zest options array pased to ::find, ::matches, etc
* @return bool True for standards mode, otherwise false.
*/
protected function isStandardsMode( $context, array $opts ): bool {
// The $opts array can force a specific mode, if key is present
if ( array_key_exists( 'standardsMode', $opts ) ) {
return (bool)$opts['standardsMode'];
}
// Otherwise guess "not standard mode" if the node document is a
// \DOMDocument, otherwise use standards mode.
$doc = self::nodeIsDocument( $context ) ?
$context : $context->ownerDocument;
return !( $doc instanceof DOMDocument );
}
/** @var ?ZestInst */
private static $singleton = null;
/**
* Create a new instance of Zest. Custom combinators and selectors
* registered for each instance of Zest do not bleed
* over into other instances.
*/
public function __construct() {
$z = self::$singleton;
$this->selectors0 = $z ? $z->selectors0 : [];
$this->selectors1 = $z ? $z->selectors1 : [];
$this->operators = $z ? $z->operators : [];
$this->combinators = $z ? $z->combinators : [];
if ( !$z ) {
$this->initRules();
$this->initSelectors();
$this->initOperators();
$this->initCombinators();
self::$singleton = $this;
// Now create another instance so that backing arrays are cloned
// @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams
self::$singleton = new ZestInst;
}
}
}

File Metadata

Mime Type
text/x-php
Expires
Wed, Aug 19, 14:07 (3 w, 4 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
6c/8f/ce1804fef3f1f43c4b147f4a7db2
Default Alt Text
ZestInst.php (50 KB)

Event Timeline