diff --git a/composer.json b/composer.json index 1e8c01e34..b9eb5a6c0 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "doctrine/dbal": "^3.10.4", "egulias/email-validator": "^4.0.4", "firebase/php-jwt": "^7.0", - "fusonic/opengraph": "^3.0.0", + "fusonic/opengraph": "^3.1.0", "gapple/structured-fields": "^2.3", "giggsey/libphonenumber-for-php-lite": "^9.0.9", "guzzlehttp/guzzle": "^7.15.2", diff --git a/composer.lock b/composer.lock index 218ad28ff..ea1cc24ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b47e22e2def372440b2f2044e1c016d2", + "content-hash": "c1c146d60e0bd653e8c435112b3942d8", "packages": [ { "name": "aws/aws-crt-php", @@ -895,16 +895,16 @@ }, { "name": "fusonic/opengraph", - "version": "v3.0.0", + "version": "v3.1.0", "source": { "type": "git", "url": "https://github.com/fusonic/opengraph.git", - "reference": "2daa6dce84f23b1bb6d66bf03b3e9371c39cd378" + "reference": "199fc2fb33a4b519e512fe33a0d2cb53455ae63c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fusonic/opengraph/zipball/2daa6dce84f23b1bb6d66bf03b3e9371c39cd378", - "reference": "2daa6dce84f23b1bb6d66bf03b3e9371c39cd378", + "url": "https://api.github.com/repos/fusonic/opengraph/zipball/199fc2fb33a4b519e512fe33a0d2cb53455ae63c", + "reference": "199fc2fb33a4b519e512fe33a0d2cb53455ae63c", "shasum": "" }, "require": { @@ -952,9 +952,9 @@ ], "support": { "issues": "https://github.com/fusonic/opengraph/issues", - "source": "https://github.com/fusonic/opengraph/tree/v3.0.0" + "source": "https://github.com/fusonic/opengraph/tree/v3.1.0" }, - "time": "2025-01-13T07:23:24+00:00" + "time": "2026-05-11T17:33:04+00:00" }, { "name": "gapple/structured-fields", @@ -1975,16 +1975,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -1992,7 +1992,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -2036,9 +2036,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "mexitek/phpcolors", @@ -4488,20 +4488,20 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.24", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -4533,7 +4533,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" }, "funding": [ { @@ -4553,7 +4553,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/deprecation-contracts", diff --git a/composer/autoload_classmap.php b/composer/autoload_classmap.php index 2d0b3124c..ed04b300a 100644 --- a/composer/autoload_classmap.php +++ b/composer/autoload_classmap.php @@ -3148,11 +3148,13 @@ 'Symfony\\Component\\CssSelector\\Node\\ElementNode' => $vendorDir . '/symfony/css-selector/Node/ElementNode.php', 'Symfony\\Component\\CssSelector\\Node\\FunctionNode' => $vendorDir . '/symfony/css-selector/Node/FunctionNode.php', 'Symfony\\Component\\CssSelector\\Node\\HashNode' => $vendorDir . '/symfony/css-selector/Node/HashNode.php', + 'Symfony\\Component\\CssSelector\\Node\\MatchingNode' => $vendorDir . '/symfony/css-selector/Node/MatchingNode.php', 'Symfony\\Component\\CssSelector\\Node\\NegationNode' => $vendorDir . '/symfony/css-selector/Node/NegationNode.php', 'Symfony\\Component\\CssSelector\\Node\\NodeInterface' => $vendorDir . '/symfony/css-selector/Node/NodeInterface.php', 'Symfony\\Component\\CssSelector\\Node\\PseudoNode' => $vendorDir . '/symfony/css-selector/Node/PseudoNode.php', 'Symfony\\Component\\CssSelector\\Node\\SelectorNode' => $vendorDir . '/symfony/css-selector/Node/SelectorNode.php', 'Symfony\\Component\\CssSelector\\Node\\Specificity' => $vendorDir . '/symfony/css-selector/Node/Specificity.php', + 'Symfony\\Component\\CssSelector\\Node\\SpecificityAdjustmentNode' => $vendorDir . '/symfony/css-selector/Node/SpecificityAdjustmentNode.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\CommentHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/CommentHandler.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HandlerInterface' => $vendorDir . '/symfony/css-selector/Parser/Handler/HandlerInterface.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HashHandler' => $vendorDir . '/symfony/css-selector/Parser/Handler/HashHandler.php', diff --git a/composer/autoload_static.php b/composer/autoload_static.php index a75415bdf..6bbae5c06 100644 --- a/composer/autoload_static.php +++ b/composer/autoload_static.php @@ -3703,11 +3703,13 @@ class ComposerStaticInit2f23f73bc0cc116b4b1eee1521aa8652 'Symfony\\Component\\CssSelector\\Node\\ElementNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/ElementNode.php', 'Symfony\\Component\\CssSelector\\Node\\FunctionNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/FunctionNode.php', 'Symfony\\Component\\CssSelector\\Node\\HashNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/HashNode.php', + 'Symfony\\Component\\CssSelector\\Node\\MatchingNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/MatchingNode.php', 'Symfony\\Component\\CssSelector\\Node\\NegationNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/NegationNode.php', 'Symfony\\Component\\CssSelector\\Node\\NodeInterface' => __DIR__ . '/..' . '/symfony/css-selector/Node/NodeInterface.php', 'Symfony\\Component\\CssSelector\\Node\\PseudoNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/PseudoNode.php', 'Symfony\\Component\\CssSelector\\Node\\SelectorNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/SelectorNode.php', 'Symfony\\Component\\CssSelector\\Node\\Specificity' => __DIR__ . '/..' . '/symfony/css-selector/Node/Specificity.php', + 'Symfony\\Component\\CssSelector\\Node\\SpecificityAdjustmentNode' => __DIR__ . '/..' . '/symfony/css-selector/Node/SpecificityAdjustmentNode.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\CommentHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/CommentHandler.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HandlerInterface' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/HandlerInterface.php', 'Symfony\\Component\\CssSelector\\Parser\\Handler\\HashHandler' => __DIR__ . '/..' . '/symfony/css-selector/Parser/Handler/HashHandler.php', diff --git a/composer/installed.json b/composer/installed.json index 00439f648..cdc6b600c 100644 --- a/composer/installed.json +++ b/composer/installed.json @@ -928,17 +928,17 @@ }, { "name": "fusonic/opengraph", - "version": "v3.0.0", - "version_normalized": "3.0.0.0", + "version": "v3.1.0", + "version_normalized": "3.1.0.0", "source": { "type": "git", "url": "https://github.com/fusonic/opengraph.git", - "reference": "2daa6dce84f23b1bb6d66bf03b3e9371c39cd378" + "reference": "199fc2fb33a4b519e512fe33a0d2cb53455ae63c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fusonic/opengraph/zipball/2daa6dce84f23b1bb6d66bf03b3e9371c39cd378", - "reference": "2daa6dce84f23b1bb6d66bf03b3e9371c39cd378", + "url": "https://api.github.com/repos/fusonic/opengraph/zipball/199fc2fb33a4b519e512fe33a0d2cb53455ae63c", + "reference": "199fc2fb33a4b519e512fe33a0d2cb53455ae63c", "shasum": "" }, "require": { @@ -963,7 +963,7 @@ "nyholm/psr7": "^1.8", "symfony/http-client": "^5.4 || ^6.4 || ^7.1" }, - "time": "2025-01-13T07:23:24+00:00", + "time": "2026-05-11T17:33:04+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -988,7 +988,7 @@ ], "support": { "issues": "https://github.com/fusonic/opengraph/issues", - "source": "https://github.com/fusonic/opengraph/tree/v3.0.0" + "source": "https://github.com/fusonic/opengraph/tree/v3.1.0" }, "install-path": "../fusonic/opengraph" }, @@ -2053,17 +2053,17 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", - "version_normalized": "2.10.0.0", + "version": "2.10.1", + "version_normalized": "2.10.1.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -2071,9 +2071,9 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, - "time": "2025-07-25T09:04:22+00:00", + "time": "2026-06-23T18:43:15+00:00", "type": "library", "extra": { "branch-alias": { @@ -2117,7 +2117,7 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, "install-path": "../masterminds/html5" }, @@ -4697,23 +4697,23 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.24", - "version_normalized": "6.4.24.0", + "version": "v7.4.9", + "version_normalized": "7.4.9.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, - "time": "2025-07-10T08:14:14+00:00", + "time": "2026-04-18T13:18:21+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -4745,7 +4745,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" }, "funding": [ { diff --git a/composer/installed.php b/composer/installed.php index c0b1a2807..b702b980f 100644 --- a/composer/installed.php +++ b/composer/installed.php @@ -128,9 +128,9 @@ 'dev_requirement' => false, ), 'fusonic/opengraph' => array( - 'pretty_version' => 'v3.0.0', - 'version' => '3.0.0.0', - 'reference' => '2daa6dce84f23b1bb6d66bf03b3e9371c39cd378', + 'pretty_version' => 'v3.1.0', + 'version' => '3.1.0.0', + 'reference' => '199fc2fb33a4b519e512fe33a0d2cb53455ae63c', 'type' => 'library', 'install_path' => __DIR__ . '/../fusonic/opengraph', 'aliases' => array(), @@ -263,9 +263,9 @@ 'dev_requirement' => false, ), 'masterminds/html5' => array( - 'pretty_version' => '2.10.0', - 'version' => '2.10.0.0', - 'reference' => 'fcf91eb64359852f00d921887b219479b4f21251', + 'pretty_version' => '2.10.1', + 'version' => '2.10.1.0', + 'reference' => 'fd5018f6815fff903946d0564977b44ce8010e29', 'type' => 'library', 'install_path' => __DIR__ . '/../masterminds/html5', 'aliases' => array(), @@ -695,9 +695,9 @@ 'dev_requirement' => false, ), 'symfony/css-selector' => array( - 'pretty_version' => 'v6.4.24', - 'version' => '6.4.24.0', - 'reference' => '9b784413143701aa3c94ac1869a159a9e53e8761', + 'pretty_version' => 'v7.4.9', + 'version' => '7.4.9.0', + 'reference' => 'b75663ed96cf4756e28e3105476f220f92886cc4', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/css-selector', 'aliases' => array(), diff --git a/fusonic/opengraph/src/Elements/Image.php b/fusonic/opengraph/src/Elements/Image.php index 90873364d..a319c621f 100644 --- a/fusonic/opengraph/src/Elements/Image.php +++ b/fusonic/opengraph/src/Elements/Image.php @@ -46,6 +46,11 @@ class Image extends ElementBase */ public ?bool $userGenerated = null; + /** + * Alternative text for the image. + */ + public ?string $alt = null; + /** * @param string $url URL to the image file */ @@ -84,6 +89,10 @@ public function getProperties(): array $properties[] = new Property(Property::IMAGE_WIDTH, $this->width); } + if (null !== $this->alt) { + $properties[] = new Property(Property::IMAGE_ALT, $this->alt); + } + if (null !== $this->userGenerated) { $properties[] = new Property(Property::IMAGE_USER_GENERATED, $this->userGenerated); } diff --git a/fusonic/opengraph/src/Objects/ObjectBase.php b/fusonic/opengraph/src/Objects/ObjectBase.php index 40e3bf97d..cd4e1da10 100644 --- a/fusonic/opengraph/src/Objects/ObjectBase.php +++ b/fusonic/opengraph/src/Objects/ObjectBase.php @@ -148,6 +148,7 @@ public function assignProperties(array $properties, bool $debug = false): void case Property::IMAGE_SECURE_URL: case Property::IMAGE_TYPE: case Property::IMAGE_WIDTH: + case Property::IMAGE_ALT: case Property::IMAGE_USER_GENERATED: if (\count($this->images) > 0) { $this->handleImageAttribute($this->images[\count($this->images) - 1], $name, $value); @@ -223,6 +224,9 @@ private function handleImageAttribute(Image $element, string $name, string $valu case Property::IMAGE_WIDTH: $element->width = (int) $value; break; + case Property::IMAGE_ALT: + $element->alt = $value; + break; case Property::IMAGE_TYPE: $element->type = $value; break; diff --git a/fusonic/opengraph/src/Property.php b/fusonic/opengraph/src/Property.php index 00fe7beaf..1df9ba75b 100644 --- a/fusonic/opengraph/src/Property.php +++ b/fusonic/opengraph/src/Property.php @@ -26,6 +26,7 @@ class Property public const IMAGE_TYPE = 'og:image:type'; public const IMAGE_URL = 'og:image:url'; public const IMAGE_WIDTH = 'og:image:width'; + public const IMAGE_ALT = 'og:image:alt'; public const IMAGE_USER_GENERATED = 'og:image:user_generated'; public const LOCALE = 'og:locale'; public const LOCALE_ALTERNATE = 'og:locale:alternate'; diff --git a/masterminds/html5/src/HTML5/Parser/Tokenizer.php b/masterminds/html5/src/HTML5/Parser/Tokenizer.php index 77e268fb3..653903c9f 100644 --- a/masterminds/html5/src/HTML5/Parser/Tokenizer.php +++ b/masterminds/html5/src/HTML5/Parser/Tokenizer.php @@ -1208,6 +1208,10 @@ protected function decodeCharacterReference($inAttribute = false) */ protected function is_alpha($input) { + if (!is_string($input) || 1 !== strlen($input)) { + return false; + } + $code = ord($input); return ($code >= 97 && $code <= 122) || ($code >= 65 && $code <= 90); diff --git a/masterminds/html5/src/HTML5/Serializer/OutputRules.php b/masterminds/html5/src/HTML5/Serializer/OutputRules.php index 13cbdc66b..308340cd0 100644 --- a/masterminds/html5/src/HTML5/Serializer/OutputRules.php +++ b/masterminds/html5/src/HTML5/Serializer/OutputRules.php @@ -454,7 +454,7 @@ protected function closeTag($ele) */ protected function wr($text) { - fwrite($this->out, $text); + fwrite($this->out, (string) $text); return $this; } diff --git a/symfony/css-selector/CssSelectorConverter.php b/symfony/css-selector/CssSelectorConverter.php index 7120a2950..a90cb8d6e 100644 --- a/symfony/css-selector/CssSelectorConverter.php +++ b/symfony/css-selector/CssSelectorConverter.php @@ -26,6 +26,8 @@ */ class CssSelectorConverter { + public static int $maxCachedItems = 1024; + private Translator $translator; private array $cache; @@ -62,6 +64,21 @@ public function __construct(bool $html = true) */ public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string { - return $this->cache[$prefix][$cssExpr] ??= $this->translator->cssToXPath($cssExpr, $prefix); + $cacheKey = $prefix."\0".$cssExpr; + + if (isset($this->cache[$cacheKey])) { + // Move the item last in cache (LRU) + $value = $this->cache[$cacheKey]; + unset($this->cache[$cacheKey]); + + return $this->cache[$cacheKey] = $value; + } + + if (\count($this->cache) >= self::$maxCachedItems) { + // Evict the oldest entry + unset($this->cache[array_key_first($this->cache)]); + } + + return $this->cache[$cacheKey] = $this->translator->cssToXPath($cssExpr, $prefix); } } diff --git a/symfony/css-selector/Node/AttributeNode.php b/symfony/css-selector/Node/AttributeNode.php index 967cb7ce0..9bcb3a4e1 100644 --- a/symfony/css-selector/Node/AttributeNode.php +++ b/symfony/css-selector/Node/AttributeNode.php @@ -23,19 +23,13 @@ */ class AttributeNode extends AbstractNode { - private NodeInterface $selector; - private ?string $namespace; - private string $attribute; - private string $operator; - private ?string $value; - - public function __construct(NodeInterface $selector, ?string $namespace, string $attribute, string $operator, ?string $value) - { - $this->selector = $selector; - $this->namespace = $namespace; - $this->attribute = $attribute; - $this->operator = $operator; - $this->value = $value; + public function __construct( + private NodeInterface $selector, + private ?string $namespace, + private string $attribute, + private string $operator, + private ?string $value, + ) { } public function getSelector(): NodeInterface diff --git a/symfony/css-selector/Node/ClassNode.php b/symfony/css-selector/Node/ClassNode.php index 7cca6d90b..e9862c31c 100644 --- a/symfony/css-selector/Node/ClassNode.php +++ b/symfony/css-selector/Node/ClassNode.php @@ -23,13 +23,10 @@ */ class ClassNode extends AbstractNode { - private NodeInterface $selector; - private string $name; - - public function __construct(NodeInterface $selector, string $name) - { - $this->selector = $selector; - $this->name = $name; + public function __construct( + private NodeInterface $selector, + private string $name, + ) { } public function getSelector(): NodeInterface diff --git a/symfony/css-selector/Node/CombinedSelectorNode.php b/symfony/css-selector/Node/CombinedSelectorNode.php index 1db62a229..78a2fd3e7 100644 --- a/symfony/css-selector/Node/CombinedSelectorNode.php +++ b/symfony/css-selector/Node/CombinedSelectorNode.php @@ -23,15 +23,11 @@ */ class CombinedSelectorNode extends AbstractNode { - private NodeInterface $selector; - private string $combinator; - private NodeInterface $subSelector; - - public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector) - { - $this->selector = $selector; - $this->combinator = $combinator; - $this->subSelector = $subSelector; + public function __construct( + private NodeInterface $selector, + private string $combinator, + private NodeInterface $subSelector, + ) { } public function getSelector(): NodeInterface diff --git a/symfony/css-selector/Node/ElementNode.php b/symfony/css-selector/Node/ElementNode.php index bd4a7fe45..9bfbd0882 100644 --- a/symfony/css-selector/Node/ElementNode.php +++ b/symfony/css-selector/Node/ElementNode.php @@ -23,13 +23,10 @@ */ class ElementNode extends AbstractNode { - private ?string $namespace; - private ?string $element; - - public function __construct(?string $namespace = null, ?string $element = null) - { - $this->namespace = $namespace; - $this->element = $element; + public function __construct( + private ?string $namespace = null, + private ?string $element = null, + ) { } public function getNamespace(): ?string diff --git a/symfony/css-selector/Node/FunctionNode.php b/symfony/css-selector/Node/FunctionNode.php index 09342a1bf..9bc3c248d 100644 --- a/symfony/css-selector/Node/FunctionNode.php +++ b/symfony/css-selector/Node/FunctionNode.php @@ -25,18 +25,17 @@ */ class FunctionNode extends AbstractNode { - private NodeInterface $selector; private string $name; - private array $arguments; /** * @param Token[] $arguments */ - public function __construct(NodeInterface $selector, string $name, array $arguments = []) - { - $this->selector = $selector; + public function __construct( + private NodeInterface $selector, + string $name, + private array $arguments = [], + ) { $this->name = strtolower($name); - $this->arguments = $arguments; } public function getSelector(): NodeInterface @@ -64,7 +63,7 @@ public function getSpecificity(): Specificity public function __toString(): string { - $arguments = implode(', ', array_map(fn (Token $token) => "'".$token->getValue()."'", $this->arguments)); + $arguments = implode(', ', array_map(static fn (Token $token) => "'".$token->getValue()."'", $this->arguments)); return \sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : ''); } diff --git a/symfony/css-selector/Node/HashNode.php b/symfony/css-selector/Node/HashNode.php index 014ae54ec..b3fb3c9bd 100644 --- a/symfony/css-selector/Node/HashNode.php +++ b/symfony/css-selector/Node/HashNode.php @@ -23,13 +23,10 @@ */ class HashNode extends AbstractNode { - private NodeInterface $selector; - private string $id; - - public function __construct(NodeInterface $selector, string $id) - { - $this->selector = $selector; - $this->id = $id; + public function __construct( + private NodeInterface $selector, + private string $id, + ) { } public function getSelector(): NodeInterface diff --git a/symfony/css-selector/Node/MatchingNode.php b/symfony/css-selector/Node/MatchingNode.php new file mode 100644 index 000000000..76d516de0 --- /dev/null +++ b/symfony/css-selector/Node/MatchingNode.php @@ -0,0 +1,55 @@ + + * + * 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 ":is()" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Hubert Lenoir + * + * @internal + */ +class MatchingNode extends AbstractNode +{ + /** + * @param array $arguments + */ + public function __construct( + public readonly NodeInterface $selector, + public readonly array $arguments = [], + ) { + } + + public function getSpecificity(): Specificity + { + $argumentsSpecificity = array_reduce( + $this->arguments, + static fn ($c, $n) => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c, + new Specificity(0, 0, 0), + ); + + return $this->selector->getSpecificity()->plus($argumentsSpecificity); + } + + public function __toString(): string + { + $selectorArguments = array_map( + static fn ($n): string => ltrim((string) $n, '*'), + $this->arguments, + ); + + return \sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments)); + } +} diff --git a/symfony/css-selector/Node/NegationNode.php b/symfony/css-selector/Node/NegationNode.php index 967b65bb5..c14c33d88 100644 --- a/symfony/css-selector/Node/NegationNode.php +++ b/symfony/css-selector/Node/NegationNode.php @@ -23,13 +23,10 @@ */ class NegationNode extends AbstractNode { - private NodeInterface $selector; - private NodeInterface $subSelector; - - public function __construct(NodeInterface $selector, NodeInterface $subSelector) - { - $this->selector = $selector; - $this->subSelector = $subSelector; + public function __construct( + private NodeInterface $selector, + private NodeInterface $subSelector, + ) { } public function getSelector(): NodeInterface diff --git a/symfony/css-selector/Node/PseudoNode.php b/symfony/css-selector/Node/PseudoNode.php index cbebd9f45..d1082e862 100644 --- a/symfony/css-selector/Node/PseudoNode.php +++ b/symfony/css-selector/Node/PseudoNode.php @@ -23,12 +23,12 @@ */ class PseudoNode extends AbstractNode { - private NodeInterface $selector; private string $identifier; - public function __construct(NodeInterface $selector, string $identifier) - { - $this->selector = $selector; + public function __construct( + private NodeInterface $selector, + string $identifier, + ) { $this->identifier = strtolower($identifier); } diff --git a/symfony/css-selector/Node/SelectorNode.php b/symfony/css-selector/Node/SelectorNode.php index 6b70535c9..f36e54c62 100644 --- a/symfony/css-selector/Node/SelectorNode.php +++ b/symfony/css-selector/Node/SelectorNode.php @@ -23,12 +23,12 @@ */ class SelectorNode extends AbstractNode { - private NodeInterface $tree; private ?string $pseudoElement; - public function __construct(NodeInterface $tree, ?string $pseudoElement = null) - { - $this->tree = $tree; + public function __construct( + private NodeInterface $tree, + ?string $pseudoElement = null, + ) { $this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null; } diff --git a/symfony/css-selector/Node/Specificity.php b/symfony/css-selector/Node/Specificity.php index bb8e5e346..c669a395c 100644 --- a/symfony/css-selector/Node/Specificity.php +++ b/symfony/css-selector/Node/Specificity.php @@ -29,15 +29,11 @@ class Specificity public const B_FACTOR = 10; public const C_FACTOR = 1; - private int $a; - private int $b; - private int $c; - - public function __construct(int $a, int $b, int $c) - { - $this->a = $a; - $this->b = $b; - $this->c = $c; + public function __construct( + private int $a, + private int $b, + private int $c, + ) { } public function plus(self $specificity): self diff --git a/symfony/css-selector/Node/SpecificityAdjustmentNode.php b/symfony/css-selector/Node/SpecificityAdjustmentNode.php new file mode 100644 index 000000000..d1bc5c94f --- /dev/null +++ b/symfony/css-selector/Node/SpecificityAdjustmentNode.php @@ -0,0 +1,49 @@ + + * + * 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 ":where()" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * + * @author Hubert Lenoir + * + * @internal + */ +class SpecificityAdjustmentNode extends AbstractNode +{ + /** + * @param array $arguments + */ + public function __construct( + public readonly NodeInterface $selector, + public readonly array $arguments = [], + ) { + } + + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity(); + } + + public function __toString(): string + { + $selectorArguments = array_map( + static fn ($n) => ltrim((string) $n, '*'), + $this->arguments, + ); + + return \sprintf('%s[%s:where(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments)); + } +} diff --git a/symfony/css-selector/Parser/Handler/HashHandler.php b/symfony/css-selector/Parser/Handler/HashHandler.php index b29042f56..0be46528f 100644 --- a/symfony/css-selector/Parser/Handler/HashHandler.php +++ b/symfony/css-selector/Parser/Handler/HashHandler.php @@ -29,13 +29,10 @@ */ class HashHandler implements HandlerInterface { - private TokenizerPatterns $patterns; - private TokenizerEscaping $escaping; - - public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) - { - $this->patterns = $patterns; - $this->escaping = $escaping; + public function __construct( + private TokenizerPatterns $patterns, + private TokenizerEscaping $escaping, + ) { } public function handle(Reader $reader, TokenStream $stream): bool diff --git a/symfony/css-selector/Parser/Handler/IdentifierHandler.php b/symfony/css-selector/Parser/Handler/IdentifierHandler.php index 25c0761e9..7e4356b7b 100644 --- a/symfony/css-selector/Parser/Handler/IdentifierHandler.php +++ b/symfony/css-selector/Parser/Handler/IdentifierHandler.php @@ -29,13 +29,10 @@ */ class IdentifierHandler implements HandlerInterface { - private TokenizerPatterns $patterns; - private TokenizerEscaping $escaping; - - public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) - { - $this->patterns = $patterns; - $this->escaping = $escaping; + public function __construct( + private TokenizerPatterns $patterns, + private TokenizerEscaping $escaping, + ) { } public function handle(Reader $reader, TokenStream $stream): bool diff --git a/symfony/css-selector/Parser/Handler/NumberHandler.php b/symfony/css-selector/Parser/Handler/NumberHandler.php index e3eb7afe3..38cc9b1f1 100644 --- a/symfony/css-selector/Parser/Handler/NumberHandler.php +++ b/symfony/css-selector/Parser/Handler/NumberHandler.php @@ -28,11 +28,9 @@ */ class NumberHandler implements HandlerInterface { - private TokenizerPatterns $patterns; - - public function __construct(TokenizerPatterns $patterns) - { - $this->patterns = $patterns; + public function __construct( + private TokenizerPatterns $patterns, + ) { } public function handle(Reader $reader, TokenStream $stream): bool diff --git a/symfony/css-selector/Parser/Handler/StringHandler.php b/symfony/css-selector/Parser/Handler/StringHandler.php index d5c6d3618..41afb875b 100644 --- a/symfony/css-selector/Parser/Handler/StringHandler.php +++ b/symfony/css-selector/Parser/Handler/StringHandler.php @@ -31,20 +31,17 @@ */ class StringHandler implements HandlerInterface { - private TokenizerPatterns $patterns; - private TokenizerEscaping $escaping; - - public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping) - { - $this->patterns = $patterns; - $this->escaping = $escaping; + public function __construct( + private TokenizerPatterns $patterns, + private TokenizerEscaping $escaping, + ) { } public function handle(Reader $reader, TokenStream $stream): bool { $quote = $reader->getSubstring(1); - if (!\in_array($quote, ["'", '"'])) { + if (!\in_array($quote, ["'", '"'], true)) { return false; } diff --git a/symfony/css-selector/Parser/Parser.php b/symfony/css-selector/Parser/Parser.php index 309c9b521..b5dc9322d 100644 --- a/symfony/css-selector/Parser/Parser.php +++ b/symfony/css-selector/Parser/Parser.php @@ -57,9 +57,9 @@ public static function parseSeries(array $tokens): array } } - $joined = trim(implode('', array_map(fn (Token $token) => $token->getValue(), $tokens))); + $joined = trim(implode('', array_map(static fn (Token $token) => $token->getValue(), $tokens))); - $int = function ($string) { + $int = static function ($string) { if (!is_numeric($string)) { throw SyntaxErrorException::stringAsFunctionArgument(); } @@ -87,13 +87,17 @@ public static function parseSeries(array $tokens): array ]; } - private function parseSelectorList(TokenStream $stream): array + private function parseSelectorList(TokenStream $stream, bool $isArgument = false): array { $stream->skipWhitespace(); $selectors = []; while (true) { - $selectors[] = $this->parserSelectorNode($stream); + if ($isArgument && $stream->getPeek()->isDelimiter([')'])) { + break; + } + + $selectors[] = $this->parserSelectorNode($stream, $isArgument); if ($stream->getPeek()->isDelimiter([','])) { $stream->getNext(); @@ -106,15 +110,19 @@ private function parseSelectorList(TokenStream $stream): array return $selectors; } - private function parserSelectorNode(TokenStream $stream): Node\SelectorNode + private function parserSelectorNode(TokenStream $stream, bool $isArgument = false): Node\SelectorNode { - [$result, $pseudoElement] = $this->parseSimpleSelector($stream); + [$result, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument); while (true) { $stream->skipWhitespace(); $peek = $stream->getPeek(); - if ($peek->isFileEnd() || $peek->isDelimiter([','])) { + if ( + $peek->isFileEnd() + || $peek->isDelimiter([',']) + || ($isArgument && $peek->isDelimiter([')'])) + ) { break; } @@ -129,7 +137,7 @@ private function parserSelectorNode(TokenStream $stream): Node\SelectorNode $combinator = ' '; } - [$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream); + [$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument); $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector); } @@ -141,7 +149,7 @@ private function parserSelectorNode(TokenStream $stream): Node\SelectorNode * * @throws SyntaxErrorException */ - private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array + private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array { $stream->skipWhitespace(); @@ -154,7 +162,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = if ($peek->isWhitespace() || $peek->isFileEnd() || $peek->isDelimiter([',', '+', '>', '~']) - || ($insideNegation && $peek->isDelimiter([')'])) + || ($isArgument && $peek->isDelimiter([')'])) ) { break; } @@ -182,7 +190,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = } $identifier = $stream->getNextIdentifier(); - if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'])) { + if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'], true)) { // Special case: CSS 2.1 pseudo-elements can have a single ':'. // Any new pseudo-element must have two. $pseudoElement = $identifier; @@ -215,7 +223,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = throw SyntaxErrorException::nestedNot(); } - [$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true); + [$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true, true); $next = $stream->getNext(); if (null !== $argumentPseudoElement) { @@ -227,6 +235,24 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = } $result = new Node\NegationNode($result, $argument); + } elseif ('is' === strtolower($identifier)) { + $selectors = $this->parseSelectorList($stream, true); + + $next = $stream->getNext(); + if (!$next->isDelimiter([')'])) { + throw SyntaxErrorException::unexpectedToken('")"', $next); + } + + $result = new Node\MatchingNode($result, $selectors); + } elseif ('where' === strtolower($identifier)) { + $selectors = $this->parseSelectorList($stream, true); + + $next = $stream->getNext(); + if (!$next->isDelimiter([')'])) { + throw SyntaxErrorException::unexpectedToken('")"', $next); + } + + $result = new Node\SpecificityAdjustmentNode($result, $selectors); } else { $arguments = []; $next = null; diff --git a/symfony/css-selector/Parser/Reader.php b/symfony/css-selector/Parser/Reader.php index 7f6ae7a60..b68d02f9d 100644 --- a/symfony/css-selector/Parser/Reader.php +++ b/symfony/css-selector/Parser/Reader.php @@ -23,13 +23,12 @@ */ class Reader { - private string $source; private int $length; private int $position = 0; - public function __construct(string $source) - { - $this->source = $source; + public function __construct( + private string $source, + ) { $this->length = \strlen($source); } @@ -53,10 +52,7 @@ public function getSubstring(int $length, int $offset = 0): string return substr($this->source, $this->position + $offset, $length); } - /** - * @return int|false - */ - public function getOffset(string $string): int|bool + public function getOffset(string $string): int|false { $position = strpos($this->source, $string, $this->position); diff --git a/symfony/css-selector/Parser/Token.php b/symfony/css-selector/Parser/Token.php index 5bf8c22ef..41e1c483f 100644 --- a/symfony/css-selector/Parser/Token.php +++ b/symfony/css-selector/Parser/Token.php @@ -31,18 +31,20 @@ class Token public const TYPE_NUMBER = 'number'; public const TYPE_STRING = 'string'; - private ?string $type; - private ?string $value; - private ?int $position; - - public function __construct(?string $type, ?string $value, ?int $position) - { - $this->type = $type; - $this->value = $value; - $this->position = $position; + /** + * @param self::TYPE_*|null $type + */ + public function __construct( + private ?string $type, + private ?string $value, + private ?int $position, + ) { } - public function getType(): ?int + /** + * @return self::TYPE_*|null + */ + public function getType(): ?string { return $this->type; } @@ -72,7 +74,7 @@ public function isDelimiter(array $values = []): bool return true; } - return \in_array($this->value, $values); + return \in_array($this->value, $values, true); } public function isWhitespace(): bool diff --git a/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php b/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php index 8c4b9f742..428e32b02 100644 --- a/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php +++ b/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php @@ -23,11 +23,9 @@ */ class TokenizerEscaping { - private TokenizerPatterns $patterns; - - public function __construct(TokenizerPatterns $patterns) - { - $this->patterns = $patterns; + public function __construct( + private TokenizerPatterns $patterns, + ) { } public function escapeUnicode(string $value): string @@ -46,7 +44,7 @@ public function escapeUnicodeAndNewLine(string $value): string private function replaceUnicodeSequences(string $value): string { - return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function ($match) { + return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), static function ($match) { $c = hexdec($match[1]); if (0x80 > $c %= 0x200000) { diff --git a/symfony/css-selector/XPath/Extension/NodeExtension.php b/symfony/css-selector/XPath/Extension/NodeExtension.php index 2ccf71a40..622677a0d 100644 --- a/symfony/css-selector/XPath/Extension/NodeExtension.php +++ b/symfony/css-selector/XPath/Extension/NodeExtension.php @@ -31,11 +31,9 @@ class NodeExtension extends AbstractExtension public const ATTRIBUTE_NAME_IN_LOWER_CASE = 2; public const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4; - private int $flags; - - public function __construct(int $flags = 0) - { - $this->flags = $flags; + public function __construct( + private int $flags = 0, + ) { } /** @@ -65,6 +63,8 @@ public function getNodeTranslators(): array 'Selector' => $this->translateSelector(...), 'CombinedSelector' => $this->translateCombinedSelector(...), 'Negation' => $this->translateNegation(...), + 'Matching' => $this->translateMatching(...), + 'SpecificityAdjustment' => $this->translateSpecificityAdjustment(...), 'Function' => $this->translateFunction(...), 'Pseudo' => $this->translatePseudo(...), 'Attribute' => $this->translateAttribute(...), @@ -97,6 +97,39 @@ public function translateNegation(Node\NegationNode $node, Translator $translato return $xpath->addCondition('0'); } + public function translateMatching(Node\MatchingNode $node, Translator $translator): XPathExpr + { + return $this->translateMatchingOrSpecificityAdjustment($node->selector, $node->arguments, $translator); + } + + public function translateSpecificityAdjustment(Node\SpecificityAdjustmentNode $node, Translator $translator): XPathExpr + { + return $this->translateMatchingOrSpecificityAdjustment($node->selector, $node->arguments, $translator); + } + + /** + * @param array $arguments + */ + private function translateMatchingOrSpecificityAdjustment(Node\NodeInterface $selector, array $arguments, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($selector); + + $conditions = []; + foreach ($arguments as $argument) { + $expr = $translator->nodeToXPath($argument); + $expr->addNameTest(); + if ('' !== $condition = $expr->getCondition()) { + $conditions[] = $condition; + } + } + + if ($conditions) { + $xpath->addCondition(1 === \count($conditions) ? $conditions[0] : '('.implode(') or (', $conditions).')'); + } + + return $xpath; + } + public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); diff --git a/symfony/css-selector/XPath/Translator.php b/symfony/css-selector/XPath/Translator.php index b2623e506..7488a2ba5 100644 --- a/symfony/css-selector/XPath/Translator.php +++ b/symfony/css-selector/XPath/Translator.php @@ -91,7 +91,6 @@ public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self { $selectors = $this->parseSelectors($cssExpr); - /** @var SelectorNode $selector */ foreach ($selectors as $index => $selector) { if (null !== $selector->getPseudoElement()) { throw new ExpressionErrorException('Pseudo-elements are not supported.'); diff --git a/symfony/css-selector/XPath/XPathExpr.php b/symfony/css-selector/XPath/XPathExpr.php index 4b1ffaa69..a148febc5 100644 --- a/symfony/css-selector/XPath/XPathExpr.php +++ b/symfony/css-selector/XPath/XPathExpr.php @@ -23,16 +23,12 @@ */ class XPathExpr { - private string $path; - private string $element; - private string $condition; - - public function __construct(string $path = '', string $element = '*', string $condition = '', bool $starPrefix = false) - { - $this->path = $path; - $this->element = $element; - $this->condition = $condition; - + public function __construct( + private string $path = '', + private string $element = '*', + private string $condition = '', + bool $starPrefix = false, + ) { if ($starPrefix) { $this->addStarPrefix(); } @@ -46,9 +42,9 @@ public function getElement(): string /** * @return $this */ - public function addCondition(string $condition): static + public function addCondition(string $condition, string $operator = 'and'): static { - $this->condition = $this->condition ? \sprintf('(%s) and (%s)', $this->condition, $condition) : $condition; + $this->condition = $this->condition ? \sprintf('(%s) %s (%s)', $this->condition, $operator, $condition) : $condition; return $this; } @@ -104,7 +100,7 @@ public function join(string $combiner, self $expr): static public function __toString(): string { $path = $this->path.$this->element; - $condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']'; + $condition = '' === $this->condition ? '' : '['.$this->condition.']'; return $path.$condition; }