value` --> `` */ public const REMOVE_TAG_ON_FAILURE = 1; /** * Removes corresponding attribute in case there's a failure * e.g. `value` --> `value` */ public const REMOVE_ATTR_ON_FAILURE = 2; /** * Removes corresponding enclosure in case there's a failure * e.g. `value` --> `value` */ public const REMOVE_ENCLOSURE_ON_FAILURE = 4; protected ?\DOMNode $mount = null; protected ?\DOMDocument $document = null; public function __construct( protected readonly LinkFactory $linkFactory, protected readonly HTML5 $parser ) {} public function __toString(): string { if (!$this->mount instanceof \DOMNode || !$this->document instanceof \DOMDocument) { return ''; } return $this->parser->saveHTML($this->mount->childNodes); } public function parse(string $html): self { // use document fragment to separate markup from default structure (html, body, ...) $fragment = $this->parser->parseFragment($html); // mount fragment to make it accessible in current document $this->mount = $this->mountFragment($fragment); $this->document = $this->mount->ownerDocument; return $this; } /** * @param string|ConsumableNonce $nonce none value to be added * @param string ...$nodeNames element node names to be processed (e.g. `style`) */ public function addNonceAttribute(string|ConsumableNonce $nonce, string ...$nodeNames): self { if ($nodeNames === []) { return $this; } $xpath = new \DOMXPath($this->document); foreach ($nodeNames as $nodeName) { $expression = sprintf('//%s[not(@*)]', $nodeName); /** @var \DOMElement $element */ foreach ($xpath->query($expression, $this->mount) as $element) { $element->setAttribute('nonce', (string)$nonce); } } return $this; } public function transformUri(string $selector, int $flags = 0): self { if (!$this->mount instanceof \DOMNode || !$this->document instanceof \DOMDocument) { return $this; } $subjects = $this->parseSelector($selector); // use xpath to traverse potential candidates having "links" $xpath = new \DOMXPath($this->document); foreach ($subjects as $subject) { $attrName = $subject['attr']; $expression = sprintf('//%s[@%s]', $subject['node'], $attrName); /** @var \DOMElement $element */ foreach ($xpath->query($expression, $this->mount) as $element) { $elementAttrValue = $element->getAttribute($attrName); $scheme = parse_url($elementAttrValue, PHP_URL_SCHEME); // skip values not having a URI-scheme if (empty($scheme)) { continue; } try { $linkResult = $this->linkFactory->createUri($elementAttrValue); } catch (UnableToLinkException $exception) { $this->onTransformUriFailure($element, $subject, $flags); continue; } $linkResultAttrValues = array_filter($linkResult->getAttributes()); // usually link results contain `href` attr value, which needs to be assigned // to a different value in case selector (e.g. `img.src` instead f `a.href`) if (isset($linkResultAttrValues['href']) && $attrName !== 'href') { $element->setAttribute($attrName, $linkResultAttrValues['href']); unset($linkResultAttrValues['href']); } foreach ($linkResultAttrValues as $name => $value) { $element->setAttribute($name, (string)$value); } } } return $this; } /** * @param \DOMElement $element current element encountered failure * @param array{node: string, attr: string} $subject node-attr combination */ protected function onTransformUriFailure(\DOMElement $element, array $subject, int $flags): void { if (($flags & self::REMOVE_TAG_ON_FAILURE) === self::REMOVE_TAG_ON_FAILURE) { $element->parentNode->removeChild($element); } elseif (($flags & self::REMOVE_ATTR_ON_FAILURE) === self::REMOVE_ATTR_ON_FAILURE) { $attrName = $subject['attr']; $element->removeAttribute($attrName); } elseif (($flags & self::REMOVE_ENCLOSURE_ON_FAILURE) === self::REMOVE_ENCLOSURE_ON_FAILURE) { // moves children out of element's enclosure, then removes (empty) element // eg `` // 1) `` // 2) `` // 3) `` // rm `` $parentNode = $element->parentNode; foreach ($element->childNodes as $child) { $cloned = $child->cloneNode(true); $parentNode->insertBefore($cloned, $element); } $parentNode->removeChild($element); } } /** * @return array{node: string, attr: string}[] */ protected function parseSelector(string $selector): array { $items = GeneralUtility::trimExplode(',', $selector, true); $items = array_map( static function (string $item): ?array { $parts = explode('.', $item); if (count($parts) !== 2) { return null; } return [ 'node' => $parts[0] ?: '*', 'attr' => $parts[1], ]; }, $items ); return array_filter($items); } protected function mountFragment(\DOMDocumentFragment $fragment): \DOMNode { $document = $fragment->ownerDocument; $mount = $document->createElement('div'); $document->appendChild($mount); if ($fragment->hasChildNodes()) { $mount->appendChild($fragment); } return $mount; } }