Commit bf25a81d authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[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's avatarcore-ci <typo3@b13.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Lina Wolf's avatarLina Wolf <112@linawolf.de>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Lina Wolf's avatarLina Wolf <112@linawolf.de>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent fc5d2f54
......@@ -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",
......
......@@ -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": "*",
......
<?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,
]);
}
}
......@@ -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']
.. 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
......@@ -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",
......
<?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);
}
}
<?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());
}
}
<?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']);