From bf25a81dc5dc3da2bd2374d2e0705ade2ac39e1d Mon Sep 17 00:00:00 2001
From: Oliver Hader <oliver@typo3.org>
Date: Wed, 8 Sep 2021 23:46:56 +0200
Subject: [PATCH] [FEATURE] Introduce <f:transform.html> view-helper

Introduces `<f:transform.html>` view-helper, providing capabilities
to resolves system internal links, like `t3://`.

Example:

   <f:transform.html selector="a.href,div.data-uri">
     <a href="t3://page?uid=1" class="page">visit</a>
     <div data-uri="t3://page?uid=1" class="page trigger">visit</div>
   </f:transform.html>

... will be resolved and transformed to the following markup ...

   <a href="https://typo3.localhost/" class="page">visit</a>
   <div data-uri="https://typo3.localhost/" class="page trigger">
     visit</div>

Following Composer dependency is made explicit:

  composer req masterminds/html5:'^2.7' ext-dom:'*'

Resolves: #95176
Releases: master
Change-Id: Ib0101fbe120343dc404f0816da6d38946df0d931
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/70977
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Lina Wolf <112@linawolf.de>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Lina Wolf <112@linawolf.de>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
---
 composer.json                                 |   2 +
 composer.lock                                 |   3 +-
 .../DependencyInjection/CommonFactory.php     |  33 +++
 typo3/sysext/core/Configuration/Services.yaml |   3 +
 ...176-IntroduceFtransformhtmlView-helper.rst |  67 ++++++
 typo3/sysext/core/composer.json               |   2 +
 .../ViewHelpers/Transform/HtmlViewHelper.php  | 116 ++++++++++
 .../Transform/HtmlViewHelperTest.php          | 206 ++++++++++++++++++
 .../frontend/Classes/Html/HtmlWorker.php      | 180 +++++++++++++++
 .../Classes/Typolink/LinkResultFactory.php    |  62 ++++++
 .../frontend/Configuration/Services.yaml      |   6 +
 11 files changed, 679 insertions(+), 1 deletion(-)
 create mode 100644 typo3/sysext/core/Classes/DependencyInjection/CommonFactory.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-95176-IntroduceFtransformhtmlView-helper.rst
 create mode 100644 typo3/sysext/fluid/Classes/ViewHelpers/Transform/HtmlViewHelper.php
 create mode 100644 typo3/sysext/fluid/Tests/Functional/ViewHelpers/Transform/HtmlViewHelperTest.php
 create mode 100644 typo3/sysext/frontend/Classes/Html/HtmlWorker.php
 create mode 100644 typo3/sysext/frontend/Classes/Typolink/LinkResultFactory.php

diff --git a/composer.json b/composer.json
index d49a26ea1b37..45d786729bc3 100644
--- a/composer.json
+++ b/composer.json
@@ -33,6 +33,7 @@
 	"require": {
 		"php": "^7.4 || ^8.0",
 		"ext-PDO": "*",
+		"ext-dom": "*",
 		"ext-json": "*",
 		"ext-libxml": "*",
 		"ext-pcre": "*",
@@ -52,6 +53,7 @@
 		"guzzlehttp/guzzle": "^7.3.0",
 		"guzzlehttp/promises": "^1.4.0",
 		"guzzlehttp/psr7": "^1.7.0 || ^2.0",
+		"masterminds/html5": "^2.7",
 		"nikic/php-parser": "^4.10.4",
 		"phpdocumentor/reflection-docblock": "^5.2",
 		"phpdocumentor/type-resolver": "^1.4",
diff --git a/composer.lock b/composer.lock
index dfe49e158eb1..edd1f5dd5f0c 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": "1b0a189cecb7784c25b2a2e33e2de299",
+    "content-hash": "5f6335f542e4863fbf3cf8b727155030",
     "packages": [
         {
             "name": "bacon/bacon-qr-code",
@@ -8270,6 +8270,7 @@
     "platform": {
         "php": "^7.4 || ^8.0",
         "ext-pdo": "*",
+        "ext-dom": "*",
         "ext-json": "*",
         "ext-libxml": "*",
         "ext-pcre": "*",
diff --git a/typo3/sysext/core/Classes/DependencyInjection/CommonFactory.php b/typo3/sysext/core/Classes/DependencyInjection/CommonFactory.php
new file mode 100644
index 000000000000..2f30ae8f06d4
--- /dev/null
+++ b/typo3/sysext/core/Classes/DependencyInjection/CommonFactory.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\DependencyInjection;
+
+use Masterminds\HTML5;
+
+/**
+ * @internal
+ */
+class CommonFactory
+{
+    public static function createHtml5Parser(): HTML5
+    {
+        return new HTML5([
+            'disable_html_ns' => true,
+        ]);
+    }
+}
diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml
index c3861e7725ae..8ded353560a1 100644
--- a/typo3/sysext/core/Configuration/Services.yaml
+++ b/typo3/sysext/core/Configuration/Services.yaml
@@ -335,3 +335,6 @@ services:
   # External dependencies
   GuzzleHttp\Client:
     factory: ['TYPO3\CMS\Core\Http\Client\GuzzleClientFactory', 'getClient']
+  Masterminds\HTML5:
+    public: true
+    factory: ['TYPO3\CMS\Core\DependencyInjection\CommonFactory', 'createHtml5Parser']
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-95176-IntroduceFtransformhtmlView-helper.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-95176-IntroduceFtransformhtmlView-helper.rst
new file mode 100644
index 000000000000..e5d82b427d83
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-95176-IntroduceFtransformhtmlView-helper.rst
@@ -0,0 +1,67 @@
+.. include:: ../../Includes.txt
+
+==========================================================
+Feature: #95176 - Introduce <f:transform.html> view helper
+==========================================================
+
+See :issue:`95176`
+
+Description
+===========
+
+Using Fluid view helper :html:`<f:format.html>` provides capabilities to
+resolve `t3://` URIs, which is used in backend contexts as well. Internally
+:html:`<f:format.html>` relies on an existing frontend context, with
+corresponding TypoScript configuration in :ts:`lib.parseFunc` being given.
+
+In order to separate concerns better, a new :html:`<f:transform.html>`
+view helper has been introduced
+
+*   to be used in frontend and backend context without relying on TypoScript,
+*   to avoid mixing parsing, sanitization and transformation concerns in
+    previously used :php:`ContentObjectRenderer::parseFunc` method of the
+    frontend rendering process.
+
+Impact
+======
+
+Individual TYPO3 link handlers (like `t3://` URIs) can be resolved and
+substituted without relying on TypoScript configuration and without mixing
+concerns in :php:`ContentObjectRenderer::parseFunc` by using Fluid view helper
+:html:`<f:transform.html>`.
+
+Syntax
+------
+
+:html:`<f:transform.html selector="[ node.attr, node.attr ]" onFailure="[ behavior ]">`
+
+*   `selector`: (optional) comma separated list of node attributes to be considered,
+    e.g. `subjects="a.href,a.data-uri,img.src"` (default `a.href`)
+*   `onFailure` (optional) corresponding behavior, in case transformation failed, e.g.
+    URI was invalid or could not be resolved properly (default `removeEnclosure`).
+    Based on example :html:`<a href="t3://INVALID">value</a>`. corresponding results
+    of each behavior would be like this:
+
+    +   `removeEnclosure`: :html:`value` (removed enclosing tag)
+    +   `removeTag`: :html:`` (removed tag, incl. child nodes)
+    +   `removeAttr`: :html:`<a>value</a>` (removed attribute)
+    +   `null`: :html:`<a href="t3://INVALID">value</a>` (unmodified, as given)
+
+Example
+-------
+
+.. code-block:: html
+
+   <f:transform.html selector="a.href,div.data-uri">
+     <a href="t3://page?uid=1" class="page">visit</a>
+     <div data-uri="t3://page?uid=1" class="page trigger">visit</div>
+   </f:transform.html>
+
+... will be resolved and transformed to the following markup ...
+
+.. code-block:: html
+
+   <a href="https://typo3.localhost/" class="page">visit</a>
+   <div data-uri="https://typo3.localhost/" class="page trigger">visit</div>
+
+.. index:: Backend, Fluid, Frontend, ext:fluid
diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json
index 30184367c653..06e7b26de53f 100644
--- a/typo3/sysext/core/composer.json
+++ b/typo3/sysext/core/composer.json
@@ -21,6 +21,7 @@
 	"require": {
 		"php": "^7.4 || ^8.0",
 		"ext-PDO": "*",
+		"ext-dom": "*",
 		"ext-json": "*",
 		"ext-libxml": "*",
 		"ext-pcre": "*",
@@ -39,6 +40,7 @@
 		"enshrined/svg-sanitize": "^0.14.1",
 		"guzzlehttp/guzzle": "^7.3.0",
 		"guzzlehttp/psr7": "^1.7.0 || ^2.0",
+		"masterminds/html5": "^2.7",
 		"nikic/php-parser": "^4.10.4",
 		"psr/container": "^1.1 || ^2.0",
 		"psr/event-dispatcher": "^1.0",
diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/Transform/HtmlViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/Transform/HtmlViewHelper.php
new file mode 100644
index 000000000000..b7ade44773d1
--- /dev/null
+++ b/typo3/sysext/fluid/Classes/ViewHelpers/Transform/HtmlViewHelper.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Fluid\ViewHelpers\Transform;
+
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Html\HtmlWorker;
+use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
+use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
+use TYPO3Fluid\Fluid\Core\ViewHelper\Exception as ViewHelperException;
+use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithRenderStatic;
+
+/**
+ * Transforms HTML and substitutes internal link scheme aspects.
+ *
+ * Examples
+ * ========
+ *
+ * Default parameters
+ * ------------------
+ *
+ * ::
+ *
+ *    <f:transform.html selector="a.href" onFailure="removeEnclosure">
+ *      <a href="t3://page?uid=1" class="home">Home</a>
+ *    </f:transform.html>
+ *
+ * Output::
+ *
+ *     <a href="https://example.com/home" class="home">Home</a>
+ *
+ * Inline notation
+ * ---------------
+ *
+ * ::
+ *
+ *    {content -> f:transform.html(selector:'a.href', onFailure:'removeEnclosure')}
+ */
+class HtmlViewHelper extends AbstractViewHelper
+{
+    use CompileWithRenderStatic;
+
+    protected const MAP_ON_FAILURE = [
+        '' => 0,
+        'null' => 0,
+        'removeTag' => HtmlWorker::REMOVE_TAG_ON_FAILURE,
+        'removeAttr' => HtmlWorker::REMOVE_ATTR_ON_FAILURE,
+        'removeEnclosure' => HtmlWorker::REMOVE_ENCLOSURE_ON_FAILURE,
+    ];
+
+    /**
+     * @var bool
+     */
+    protected $escapeChildren = false;
+
+    /**
+     * @var bool
+     */
+    protected $escapeOutput = false;
+
+    /**
+     * @throws ViewHelperException
+     */
+    public function initializeArguments()
+    {
+        $this->registerArgument(
+            'selector',
+            'string',
+            'comma separated list of node attributes to be considered',
+            false,
+            'a.href'
+        );
+        $this->registerArgument(
+            'onFailure',
+            'string',
+            'behavior on failure, either `removeTag`, `removeAttr`, `removeEnclosure` or `null`',
+            false,
+            'removeEnclosure'
+        );
+    }
+
+    /**
+     * @param array{selector: string} $arguments
+     * @param \Closure $renderChildrenClosure
+     * @param RenderingContextInterface $renderingContext
+     *
+     * @return string transformed markup
+     */
+    public static function renderStatic(array $arguments, \Closure $renderChildrenClosure, RenderingContextInterface $renderingContext)
+    {
+        $content = $renderChildrenClosure();
+        $worker = GeneralUtility::makeInstance(HtmlWorker::class);
+
+        $selector = $arguments['selector'];
+        $onFailure = $arguments['onFailure'];
+        $onFailureFlags = self::MAP_ON_FAILURE[$onFailure] ?? HtmlWorker::REMOVE_ENCLOSURE_ON_FAILURE;
+
+        return (string)$worker
+            ->parse($content)
+            ->transformUri($selector, $onFailureFlags);
+    }
+}
diff --git a/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Transform/HtmlViewHelperTest.php b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Transform/HtmlViewHelperTest.php
new file mode 100644
index 000000000000..ddd5f91bebee
--- /dev/null
+++ b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/Transform/HtmlViewHelperTest.php
@@ -0,0 +1,206 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Fluid\Tests\Functional\ViewHelpers\Transform;
+
+use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
+use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+class HtmlViewHelperTest extends FunctionalTestCase
+{
+    use SiteBasedTestTrait;
+
+    protected const LANGUAGE_PRESETS = [
+        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en_US.UTF8', 'iso' => 'en', 'hrefLang' => 'en-US', 'direction' => '']
+    ];
+
+    protected $backupGlobals = true;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->importDataSet('EXT:core/Tests/Functional/Fixtures/pages.xml');
+        $this->writeSiteConfiguration(
+            'typo3-localhost',
+            $this->buildSiteConfiguration(1, 'https://typo3.localhost/'),
+            [$this->buildDefaultLanguageConfiguration('EN', '/')]
+        );
+
+        $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://typo3.localhost/', 'GET'))
+            ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
+    }
+
+    public static function isTransformedDataProvider(): array
+    {
+        return [
+            'any HTML tag' => [
+                '<p>value a</p><p>value b</p>',
+                '<p>value a</p><p>value b</p>',
+            ],
+            'unknown HTML tag' => [
+                '<unknown>value</unknown>',
+                '<unknown>value</unknown>',
+            ],
+            'empty' => [
+                '<a href>visit</a>',
+                '<a href>visit</a>',
+            ],
+            'invalid' => [
+                '<a href="#">visit</a>',
+                '<a href="#">visit</a>',
+            ],
+            'tel anchor' => [
+                '<a href="tel:+123456789" class="phone voice">call</a>',
+                '<a href="tel:+123456789" class="phone voice">call</a>'
+            ],
+            'mailto anchor' => [
+                '<a href="mailto:test@typo3.localhost?subject=Test" class="mailto">send mail</a>',
+                '<a href="mailto:test@typo3.localhost?subject=Test" class="mailto">send mail</a>',
+            ],
+            'https anchor' => [
+                '<a href="https://typo3.localhost/path/visit.html" class="page">visit</a>',
+                '<a href="https://typo3.localhost/path/visit.html" class="page">visit</a>',
+            ],
+            'absolute anchor' => [
+                '<a href="/path/visit.html" class="page">visit</a>',
+                '<a href="/path/visit.html" class="page">visit</a>',
+            ],
+            'relative anchor' => [
+                '<a href="path/visit.html" class="page">visit</a>',
+                '<a href="path/visit.html" class="page">visit</a>',
+            ],
+            't3-page anchor' => [
+                '<a href="t3://page?uid=1" class="page">visit</a>',
+                '<a href="https://typo3.localhost/" class="page">visit</a>',
+            ],
+            't3-page without uid anchor' => [
+                '<a href="t3://page">visit</a>',
+                '<a href="https://typo3.localhost/">visit</a>',
+            ],
+        ];
+    }
+
+    /**
+     * @param string $payload
+     * @param string $expectation
+     * @test
+     * @dataProvider isTransformedDataProvider
+     */
+    public function isTransformed(string $payload, string $expectation): void
+    {
+        $view = new StandaloneView();
+        $view->setTemplateSource(sprintf('<f:transform.html>%s</f:transform.html>', $payload));
+        self::assertSame($expectation, $view->render());
+    }
+
+    public static function isTransformedWithSelectorDataProvider(): array
+    {
+        return [
+            'a.href' => [
+                'a.href',
+                '<a href="t3://page?uid=1" class="page">visit</a>',
+                '<a href="https://typo3.localhost/" class="page">visit</a>'
+            ],
+            '.href' => [
+                '.href',
+                '<a href="t3://page?uid=1" class="page">visit</a>',
+                '<a href="https://typo3.localhost/" class="page">visit</a>'
+            ],
+            'div.data-uri' => [
+                'div.data-uri',
+                '<div data-uri="t3://page?uid=1" class="page">visit</div>',
+                '<div data-uri="https://typo3.localhost/" class="page">visit</div>'
+            ],
+            'a.href,div.data-uri' => [
+                'a.href,div.data-uri',
+                '<a href="t3://page?uid=1">visit</a><div data-uri="t3://page?uid=1">visit</div>',
+                '<a href="https://typo3.localhost/">visit</a><div data-uri="https://typo3.localhost/">visit</div>',
+            ],
+        ];
+    }
+
+    /**
+     * @param string $selector
+     * @param string $payload
+     * @param string $expectation
+     * @test
+     * @dataProvider isTransformedWithSelectorDataProvider
+     */
+    public function isTransformedWithSelector(string $selector, string $payload, string $expectation): void
+    {
+        $view = new StandaloneView();
+        $view->setTemplateSource(sprintf('<f:transform.html selector="%s">%s</f:transform.html>', $selector, $payload));
+        self::assertSame($expectation, $view->render());
+    }
+
+    public static function isTransformedWithOnFailureDataProvider(): array
+    {
+        return [
+            't3-page invalid uid anchor (default)' => [
+                null,
+                '<a href="t3://page?uid=9876">visit</a>',
+                'visit',
+            ],
+            't3-page invalid uid anchor ("removeEnclosure")' => [
+                'removeEnclosure',
+                '<a href="t3://page?uid=9876">visit</a>',
+                'visit',
+            ],
+            't3-page invalid uid anchor ("removeTag")' => [
+                'removeTag',
+                '<a href="t3://page?uid=9876">visit</a>',
+                '',
+            ],
+            't3-page invalid uid anchor ("removeAttr")' => [
+                'removeAttr',
+                '<a href="t3://page?uid=9876">visit</a>',
+                '<a>visit</a>',
+            ],
+            't3-page invalid uid anchor ("null")' => [
+                'null',
+                '<a href="t3://page?uid=9876">visit</a>',
+                '<a href="t3://page?uid=9876">visit</a>',
+            ],
+            't3-page invalid uid anchor ("")' => [
+                '',
+                '<a href="t3://page?uid=9876">visit</a>',
+                '<a href="t3://page?uid=9876">visit</a>',
+            ],
+        ];
+    }
+
+    /**
+     * @param string|null $onFailure
+     * @param string $payload
+     * @param string $expectation
+     * @test
+     * @dataProvider isTransformedWithOnFailureDataProvider
+     */
+    public function isTransformedWithOnFailure(?string $onFailure, string $payload, string $expectation): void
+    {
+        $view = new StandaloneView();
+        $view->setTemplateSource(sprintf(
+            '<f:transform.html %s>%s</f:transform.html>',
+            $onFailure !== null ? 'onFailure="' . $onFailure . '"' : '',
+            $payload
+        ));
+        self::assertSame($expectation, $view->render());
+    }
+}
diff --git a/typo3/sysext/frontend/Classes/Html/HtmlWorker.php b/typo3/sysext/frontend/Classes/Html/HtmlWorker.php
new file mode 100644
index 000000000000..e16182bdd196
--- /dev/null
+++ b/typo3/sysext/frontend/Classes/Html/HtmlWorker.php
@@ -0,0 +1,180 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Frontend\Html;
+
+use DOMDocument;
+use DOMDocumentFragment;
+use DOMElement;
+use DOMNode;
+use DOMXPath;
+use Exception;
+use Masterminds\HTML5;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Typolink\LinkResultFactory;
+
+/**
+ * @internal API still might change
+ */
+class HtmlWorker
+{
+    /**
+     * Removes corresponding tag in case there's a failure
+     * e.g. `<a href="t3://!!INVALID!!">value</a>` --> ``
+     */
+    public const REMOVE_TAG_ON_FAILURE = 1;
+
+    /**
+     * Removes corresponding attribute in case there's a failure
+     * e.g. `<a href="t3://!!INVALID!!">value</a>` --> `<a>value</a>`
+     */
+    public const REMOVE_ATTR_ON_FAILURE = 2;
+
+    /**
+     * Removes corresponding enclosure in case there's a failure
+     * e.g. `<a href="t3://!!INVALID!!">value</a>` --> `value`
+     */
+    public const REMOVE_ENCLOSURE_ON_FAILURE = 4;
+
+    protected LinkResultFactory $linkResultFactory;
+    protected HTML5 $parser;
+
+    protected ?DOMNode $mount = null;
+    protected ?DOMDocument $document = null;
+
+    public function __construct(LinkResultFactory $linkResultFactory, HTML5 $parser)
+    {
+        $this->linkResultFactory = $linkResultFactory;
+        $this->parser = $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;
+    }
+
+    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->linkResultFactory->createFromUriString($elementAttrValue);
+                } catch (Exception $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, $value);
+                }
+            }
+        }
+        return $this;
+    }
+
+    /**
+     * @param DOMElement $element current element encountered failure
+     * @param array{node: string, attr: string} $subject node-attr combination
+     * @param int $flags
+     */
+    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 `<ELEMENT><a><b><c></ELEMENT><NEXT>`
+            // 1) `<ELEMENT><b><c></ELEMENT><a><NEXT>`
+            // 2) `<ELEMENT><c></ELEMENT><a><b><NEXT>`
+            // 3) `<ELEMENT></ELEMENT><a><b><c><NEXT>`
+            // rm `<a><b><c><NEXT>`
+            foreach ($element->childNodes as $child) {
+                $element->parentNode->insertBefore($child, $element->nextSibling);
+            }
+            $element->parentNode->removeChild($element);
+        }
+    }
+
+    /**
+     * @param string $selector
+     * @return array{node: string, attr: string}[]
+     */
+    protected function parseSelector(string $selector): array
+    {
+        $items = GeneralUtility::trimExplode(',', $selector, true);
+        $items = array_map(
+            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);
+        $mount->appendChild($fragment);
+        return $mount;
+    }
+}
diff --git a/typo3/sysext/frontend/Classes/Typolink/LinkResultFactory.php b/typo3/sysext/frontend/Classes/Typolink/LinkResultFactory.php
new file mode 100644
index 000000000000..65a5b286e20a
--- /dev/null
+++ b/typo3/sysext/frontend/Classes/Typolink/LinkResultFactory.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Frontend\Typolink;
+
+use TYPO3\CMS\Core\LinkHandling\LinkService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+
+/**
+ * Factory for LinkResult instances
+ */
+class LinkResultFactory
+{
+    protected LinkService $linkService;
+
+    public function __construct(LinkService $linkService)
+    {
+        $this->linkService = $linkService;
+    }
+
+    public function createFromUriString(string $uri): LinkResultInterface
+    {
+        $linkDetails = $this->linkService->resolve($uri);
+        $linkDetails['typoLinkParameter'] = $uri;
+        $linkType = $linkDetails['type'] ?? '';
+
+        $linkBuilder = $this->resolveLinkBuilder($linkType);
+        if ($linkBuilder !== null) {
+            return $linkBuilder->build($linkDetails, '', '', []);
+        }
+        return GeneralUtility::makeInstance(
+            LinkResult::class,
+            $linkDetails['type'] ?? '',
+            $linkDetails['url'] ?? $uri
+        );
+    }
+
+    protected function resolveLinkBuilder(string $linkType): ?AbstractTypolinkBuilder
+    {
+        $className = (string)($GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkType] ?? '');
+        if (!is_a($className, AbstractTypolinkBuilder::class, true)) {
+            return null;
+        }
+        $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
+        return GeneralUtility::makeInstance($className, $contentObjectRenderer);
+    }
+}
diff --git a/typo3/sysext/frontend/Configuration/Services.yaml b/typo3/sysext/frontend/Configuration/Services.yaml
index dc0765ac62ab..be4f789c40cf 100644
--- a/typo3/sysext/frontend/Configuration/Services.yaml
+++ b/typo3/sysext/frontend/Configuration/Services.yaml
@@ -36,3 +36,9 @@ services:
       - name: event.listener
         identifier: 'typo3-frontend/overlay'
         method: 'languageAndWorkspaceOverlay'
+
+  TYPO3\CMS\Frontend\Html\HtmlWorker:
+    public: true
+
+  TYPO3\CMS\Frontend\Typolink\LinkResultFactory:
+    public: true
-- 
GitLab