diff --git a/src/Symfony/Component/CssSelector/CHANGELOG.md b/src/Symfony/Component/CssSelector/CHANGELOG.md index d2b7fb1d62acf..2e28394fdf7b9 100644 --- a/src/Symfony/Component/CssSelector/CHANGELOG.md +++ b/src/Symfony/Component/CssSelector/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG --- * Add support for `:scope` + * Add support for `*:has` 4.4.0 ----- diff --git a/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php index 52d8259b86789..d54f82ebeb5f3 100644 --- a/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php +++ b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php @@ -17,7 +17,7 @@ * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon */ diff --git a/src/Symfony/Component/CssSelector/Node/RelationNode.php b/src/Symfony/Component/CssSelector/Node/RelationNode.php new file mode 100644 index 0000000000000..7f50501d17472 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/RelationNode.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\Node; + +/** + * Represents a ":has()" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. + * + * @author Franck Ranaivo-Harisoa + * + * @internal + */ +class RelationNode extends AbstractNode +{ + private NodeInterface $selector; + private NodeInterface $subSelector; + private string $combinator; + + public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector) + { + $this->selector = $selector; + $this->combinator = $combinator; + $this->subSelector = $subSelector; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getCombinator(): string + { + return $this->combinator; + } + + public function getSubSelector(): NodeInterface + { + return $this->subSelector; + } + + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); + } + + public function __toString(): string + { + return sprintf('%s[%s:has(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Parser.php b/src/Symfony/Component/CssSelector/Parser/Parser.php index f7eea2f828fc3..7c4eff8c5f0c4 100644 --- a/src/Symfony/Component/CssSelector/Parser/Parser.php +++ b/src/Symfony/Component/CssSelector/Parser/Parser.php @@ -11,6 +11,7 @@ namespace Symfony\Component\CssSelector\Parser; +use Symfony\Component\CssSelector\Exception\InternalErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node; use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; @@ -144,10 +145,42 @@ private function parserSelectorNode(TokenStream $stream, bool $isArgument = fals return new Node\SelectorNode($result, $pseudoElement); } + /** + * @throws InternalErrorException + * @throws SyntaxErrorException + */ + function parseRelativeSelector(TokenStream $stream): array + { + $stream->skipWhitespace(); + $subSelector = ''; + $next = $stream->getNext(); + + if ($next->isDelimiter(['-', '+', '>', '~'])) { + $combinator = $next->getValue(); + $stream->skipWhitespace(); + $next = $stream->getNext(); + } else { + $combinator = new Token(Token::TYPE_DELIMITER, ' ', 0); + } + + while(true){ + if ($next->isString() || $next->isIdentifier() || $next->isNumber() + || $next->isDelimiter(['.', '*'])) { + $subSelector .= $next->getValue(); + } elseif ($next->isDelimiter([')'])) { + $result = $this->parse($subSelector); + return [$combinator, $result[0]]; + } else { + throw SyntaxErrorException::unexpectedToken('an argument', $next); + } + $next = $stream->getNext(); + } + } + /** * Parses next simple node (hash, class, pseudo, negation). * - * @throws SyntaxErrorException + * @throws SyntaxErrorException|InternalErrorException */ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array { @@ -253,6 +286,9 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = } $result = new Node\SpecificityAdjustmentNode($result, $selectors); + } elseif('has' === strtolower($identifier)) { + [$combinator, $arguments] = $this->parseRelativeSelector($stream); + $result = new Node\RelationNode($result, $combinator ,$arguments); } else { $arguments = []; $next = null; diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php index 82de5ab6b8562..c309484b15115 100644 --- a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php @@ -134,6 +134,7 @@ public static function getParserTestData() ['div:contains("foo")', ["Function[Element[div]:contains(['foo'])]"]], ['div#foobar', ['Hash[Element[div]#foobar]']], ['div:not(div.foo)', ['Negation[Element[div]:not(Class[Element[div].foo])]']], + ['div:has(div.foo)', ['Relation[Element[div]:has(Selector[Class[Element[div].foo]])]']], ['td ~ th', ['CombinedSelector[Element[td] ~ Element[th]]']], ['.foo[data-bar][data-baz=0]', ["Attribute[Attribute[Class[Element[*].foo][data-bar]][data-baz = '0']]"]], ['div#foo\.bar', ['Hash[Element[div]#foo.bar]']], diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php index f521a94708423..8b255db90a205 100644 --- a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php +++ b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php @@ -235,6 +235,10 @@ public static function getCssToXPathTestData() [':scope', '*[1]'], ['e:is(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"], ['e:where(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"], + ['div:has(> .foo)', "div[./*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], + ['div:has(~ .foo)', "div[following-sibling::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], + ['div:has(+ .foo)', "div[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]"], + ['div:has(+ .foo)', "div[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]"], ]; } diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php index 495f882910d5a..30eedb8921eb5 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php @@ -15,7 +15,7 @@ * XPath expression translator abstract extension. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * @@ -47,4 +47,9 @@ public function getAttributeMatchingTranslators(): array { return []; } + + public function getRelativeCombinationTranslators(): array + { + return []; + } } diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php index 1a74b90acc6c3..1d53eba527f23 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php @@ -15,10 +15,12 @@ * XPath expression translator extension interface. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * + * @method array getRelativeCombinationTranslators() Returns combination translators found inside ":has()" relation. + * * @internal */ interface ExtensionInterface diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php index 4cd46fa1fc783..4f6f961cad9c0 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php @@ -19,7 +19,7 @@ * XPath expression translator node extension. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon * @@ -71,6 +71,7 @@ public function getNodeTranslators(): array 'Class' => $this->translateClass(...), 'Hash' => $this->translateHash(...), 'Element' => $this->translateElement(...), + 'Relation' => $this->translateRelation(...), ]; } @@ -209,6 +210,13 @@ public function translateElement(Node\ElementNode $node): XPathExpr return $xpath; } + public function translateRelation(Node\RelationNode $node, Translator $translator): XPathExpr + { + $combinator = $node->getCombinator(); + + return $translator->addRelativeCombination($combinator, $node->getSelector(), $node->getSubSelector()); + } + public function getName(): string { return 'node'; diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php new file mode 100644 index 0000000000000..d85ec21dfa069 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator combination extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. + * + * @author Franck Ranaivo-Harisoa + * + * @internal + */ +class RelationExtension extends AbstractExtension +{ + public function getRelativeCombinationTranslators(): array + { + return [ + ' ' => $this->translateRelationDescendant(...), + '>' => $this->translateRelationChild(...), + '+' => $this->translateRelationDirectAdjacent(...), + '~' => $this->translateRelationIndirectAdjacent(...), + ]; + } + + public function translateRelationDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[descendant-or-self::', $combinedXpath, ']', true); + } + + public function translateRelationChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[./', $combinedXpath, ']', true); + } + + public function translateRelationDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + $combinedXpath + ->addNameTest() + ->addCondition('position() = 1'); + + return $xpath + ->join('[following-sibling::', $combinedXpath, ']', true); + } + + public function translateRelationIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[following-sibling::', $combinedXpath, ']', true); + } + + public function getName(): string + { + return 'relation'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Translator.php b/src/Symfony/Component/CssSelector/XPath/Translator.php index b2623e5067ed4..d2670a35c7589 100644 --- a/src/Symfony/Component/CssSelector/XPath/Translator.php +++ b/src/Symfony/Component/CssSelector/XPath/Translator.php @@ -44,6 +44,7 @@ class Translator implements TranslatorInterface private array $nodeTranslators = []; private array $combinationTranslators = []; + private array $relativeCombinationTranslators = []; private array $functionTranslators = []; private array $pseudoClassTranslators = []; private array $attributeMatchingTranslators = []; @@ -58,6 +59,7 @@ public function __construct(?ParserInterface $parser = null) ->registerExtension(new Extension\FunctionExtension()) ->registerExtension(new Extension\PseudoClassExtension()) ->registerExtension(new Extension\AttributeMatchingExtension()) + ->registerExtension(new Extension\RelationExtension()) ; } @@ -120,6 +122,7 @@ public function registerExtension(Extension\ExtensionInterface $extension): stat $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators()); $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators()); $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators()); + $this->relativeCombinationTranslators = array_merge($this->relativeCombinationTranslators, $extension->getRelativeCombinationTranslators()); return $this; } @@ -170,6 +173,18 @@ public function addCombination(string $combiner, NodeInterface $xpath, NodeInter return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); } + /** + * @throws ExpressionErrorException + */ + public function addRelativeCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr + { + if (!isset($this->relativeCombinationTranslators[$combiner])) { + throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner)); + } + + return $this->relativeCombinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); + } + /** * @throws ExpressionErrorException */ diff --git a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php index a148febc53e0d..c3144b3f98ebd 100644 --- a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php +++ b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php @@ -82,7 +82,7 @@ public function addStarPrefix(): static * * @return $this */ - public function join(string $combiner, self $expr): static + public function join(string $combiner, self $expr, string $closingCombiner = null, bool $hasInnerConditions = false): static { $path = $this->__toString().$combiner; @@ -91,8 +91,19 @@ public function join(string $combiner, self $expr): static } $this->path = $path; - $this->element = $expr->element; - $this->condition = $expr->condition; + + if (!$hasInnerConditions) { + $this->element = $expr->element.($closingCombiner ?? ''); + $this->condition = $expr->condition; + } else { + $this->element = $expr->element; + if ($expr->condition) { + $this->element .= '['.$expr->condition.']'; + } + if ($closingCombiner) { + $this->element .= $closingCombiner; + } + } return $this; } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy