Skip to content

Commit

Permalink
[FEATURE] Use LinkResult to generated Typolinks in Frontend
Browse files Browse the repository at this point in the history
This change introduces a new immutable object "LinkResult" along
with an interface, containing the base result of a generated
link by TypoLink.

This object contains all information needed to put
together a <a> tag or return a pure URL.

For the time being this new class is used to build
links from the typolink link builders, and in addition
should be able to be returned fully by typolink
in the future.

In addition, this object helps to build links needed
for e.g. JSON responses to contain all information
of the link to be serialized.

Resolves: #94889
Releases: master
Change-Id: Ic9383a1a0f0ec93ba3aa352235003268e00a2f10
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/70590
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
  • Loading branch information
twoldanski authored and ohader committed Sep 2, 2021
1 parent 485af2b commit 9ccd931
Show file tree
Hide file tree
Showing 15 changed files with 675 additions and 64 deletions.
@@ -0,0 +1,32 @@
.. include:: ../../Includes.txt

======================================================================
Feature: #94889 - Add "result" option to typolink returnLast parameter
======================================================================

See :issue:`94889`

Description
===========

This change introduces a new :php:`LinkResult` object along with an
interface, containing the base result of a generated link by TypoLink.

This object should contain all information needed to put together
an :html:`<a>` tag or return a URL in the future.

For the time being this new class is used to build links from
:php:`AbstractTypolinkBuilder` implementations, and in addition
should be able to be returned fully by :ts:`typolink` in the future.

In addition, this object helps to build links needed
for e.g. JSON responses to contain all information
of the link to be serialized.

Impact
======

This feature allows user to handle link's data in more consistent way, also
simplifies typolink handling in different outputs than HTML, like i.e. JSON

.. index:: PHP-API, TypoScript, ext:frontend
@@ -0,0 +1,27 @@
.. include:: ../../Includes.txt

========================================================================================
Important: #94889 - AbstractTypoLinkBuilder::build now returns array|LinkResultInterface
========================================================================================

See :issue:`94889`

Description
===========

The method signature of :php:`AbstractTypoLinkBuilder` has changed, as
:php:`array` return type has been removed. Thus loosening the inheritance
criteria for TYPO3 v11.

In TYPO3 v12 :php:`AbstractTypoLinkBuilder` will have a
:php:`LinkResultInterface` return type.

Extensions using this class can stay compatible with two major TYPO3 LTS
versions by doing the following:

* Keeping an :php:`array` return type to stay compatible with
TYPO3 v10 and TYPO3 v11.
* Using the :php:`LinkResultInterface` return type to stay compatible with
TYPO3 v11 and TYPO3 v12+.

.. index:: Frontend, PHP-API, TypoScript, ext:frontend
Expand Up @@ -79,6 +79,8 @@
use TYPO3\CMS\Frontend\Resource\FilePathSanitizer;
use TYPO3\CMS\Frontend\Service\TypoLinkCodecService;
use TYPO3\CMS\Frontend\Typolink\AbstractTypolinkBuilder;
use TYPO3\CMS\Frontend\Typolink\LinkResult;
use TYPO3\CMS\Frontend\Typolink\LinkResultInterface;
use TYPO3\CMS\Frontend\Typolink\UnableToLinkException;
use TYPO3\HtmlSanitizer\Builder\BuilderInterface;

Expand Down Expand Up @@ -385,6 +387,8 @@ class ContentObjectRenderer implements LoggerAwareInterface
*/
public $lastTypoLinkLD = [];

public ?LinkResultInterface $lastTypoLinkResult = null;

/**
* array that registers rendered content elements (or any table) to make sure they are not rendered recursively!
*
Expand Down Expand Up @@ -4702,6 +4706,7 @@ public function typoLink($linkText, $conf)
$this->lastTypoLinkTarget = '';

$resolvedLinkParameters = $this->resolveMixedLinkParameter($linkText, $linkParameter, $conf);

// check if the link handler hook has resolved the link completely already
if (!is_array($resolvedLinkParameters)) {
return $resolvedLinkParameters;
Expand Down Expand Up @@ -4738,10 +4743,20 @@ public function typoLink($linkText, $conf)
$tsfe instanceof TypoScriptFrontendController ? $tsfe : null
);
try {
[$this->lastTypoLinkUrl, $linkText, $target] = $linkBuilder->build($linkDetails, $linkText, $target, $conf);
$this->lastTypoLinkTarget = htmlspecialchars($target);
$this->lastTypoLinkLD['target'] = htmlspecialchars($target);
$this->lastTypoLinkLD['totalUrl'] = $this->lastTypoLinkUrl;
$linkedResult = $linkBuilder->build($linkDetails, $linkText, $target, $conf);
// Legacy layer, can be removed in TYPO3 v12.0.
if (!($linkedResult instanceof LinkResultInterface)) {
if (is_array($linkedResult)) {
[$url, $linkText, $target] = $linkedResult;
} else {
$url = '';
}
$linkedResult = new LinkResult($linkDetails['type'], $url);
$linkedResult = $linkedResult
->withTarget($target)
->withLinkConfiguration($conf)
->withLinkText($linkText);
}
} catch (UnableToLinkException $e) {
$this->logger->debug('Unable to link "{text}"', [
'text' => $e->getLinkText(),
Expand All @@ -4752,20 +4767,27 @@ public function typoLink($linkText, $conf)
return $e->getLinkText();
}
} elseif (isset($linkDetails['url'])) {
$this->lastTypoLinkUrl = $linkDetails['url'];
$this->lastTypoLinkTarget = htmlspecialchars($target);
$this->lastTypoLinkLD['target'] = htmlspecialchars($target);
$this->lastTypoLinkLD['totalUrl'] = $this->lastTypoLinkUrl;
$linkedResult = new LinkResult($linkDetails['type'], $linkDetails['url']);
$linkedResult = $linkedResult
->withTarget($target)
->withLinkConfiguration($conf)
->withLinkText($linkText);
} else {
return $linkText;
}

$this->lastTypoLinkResult = $linkedResult;
$this->lastTypoLinkTarget = $linkedResult->getTarget();
$this->lastTypoLinkUrl = $linkedResult->getUrl();
$this->lastTypoLinkLD['target'] = htmlspecialchars($linkedResult->getTarget());
$this->lastTypoLinkLD['totalUrl'] = $linkedResult->getUrl();

// We need to backup the URL because ATagParams might call typolink again and change the last URL.
$url = $this->lastTypoLinkUrl;
$finalTagParts = [
'aTagParams' => $this->getATagParams($conf),
'url' => $url,
'TYPE' => $linkDetails['type']
'TYPE' => $linkedResult->getType()
];

// Ensure "href" is not in the list of aTagParams to avoid double tags, usually happens within buggy parseFunc settings
Expand All @@ -4791,7 +4813,7 @@ public function typoLink($linkText, $conf)
// Check, if the target is coded as a JS open window link:
$JSwindowParts = [];
$JSwindowParams = '';
if ($target && preg_match('/^([0-9]+)x([0-9]+)(:(.*)|.*)$/', $target, $JSwindowParts)) {
if ($this->lastTypoLinkResult->getTarget() && preg_match('/^([0-9]+)x([0-9]+)(:(.*)|.*)$/', $this->lastTypoLinkResult->getTarget(), $JSwindowParts)) {
// Take all pre-configured and inserted parameters and compile parameter list, including width+height:
$JSwindow_tempParamsArr = GeneralUtility::trimExplode(',', strtolower(($conf['JSwindow_params'] ?? '') . ',' . ($JSwindowParts[4] ?? '')), true);
$JSwindow_paramsArr = [];
Expand All @@ -4805,25 +4827,27 @@ public function typoLink($linkText, $conf)
$JSwindow_paramsArr[$JSp] = $JSp . '=' . $JSv;
}
}
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttribute('target', $target);
// Add width/height:
$JSwindow_paramsArr['width'] = 'width=' . $JSwindowParts[1];
$JSwindow_paramsArr['height'] = 'height=' . $JSwindowParts[2];
// Imploding into string:
$JSwindowParams = implode(',', $JSwindow_paramsArr);
}

if (!$JSwindowParams && $linkDetails['type'] === LinkService::TYPE_EMAIL && $tsfe->spamProtectEmailAddresses === 'ascii') {
if (!$JSwindowParams && $linkedResult->getType() === LinkService::TYPE_EMAIL && $tsfe->spamProtectEmailAddresses === 'ascii') {
$tagAttributes['href'] = $finalTagParts['url'];
} else {
$tagAttributes['href'] = htmlspecialchars($finalTagParts['url']);
}
if (!empty($title)) {
$tagAttributes['title'] = htmlspecialchars($title);
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttribute('title', $title);
}

// Target attribute
if (!empty($target)) {
$tagAttributes['target'] = htmlspecialchars($target);
if (!empty($this->lastTypoLinkResult->getTarget())) {
$tagAttributes['target'] = htmlspecialchars($this->lastTypoLinkResult->getTarget());
}
if ($JSwindowParams && in_array($tsfe->xhtmlDoctype, ['xhtml_strict', 'xhtml_11'], true)) {
// Create TARGET-attribute only if the right doctype is used
Expand All @@ -4832,45 +4856,58 @@ public function typoLink($linkText, $conf)

if ($JSwindowParams) {
$onClick = 'openPic(' . GeneralUtility::quoteJSvalue($tsfe->baseUrlWrap($finalTagParts['url']))
. ',' . GeneralUtility::quoteJSvalue($target) . ','
. ',' . GeneralUtility::quoteJSvalue($this->lastTypoLinkResult->getTarget()) . ','
. GeneralUtility::quoteJSvalue($JSwindowParams)
. ');return false;';
$tagAttributes['onclick'] = htmlspecialchars($onClick);
GeneralUtility::makeInstance(AssetCollector::class)->addInlineJavaScript('openPic', 'function openPic(url, winName, winParams) { var theWindow = window.open(url, winName, winParams); if (theWindow) { theWindow.focus(); } }');
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttribute('onclick', $onClick);
}

if (!empty($resolvedLinkParameters['class'])) {
$tagAttributes['class'] = htmlspecialchars($resolvedLinkParameters['class']);
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttribute('class', $tagAttributes['class']);
}

// Prevent trouble with double and missing spaces between attributes and merge params before implode
// (skip decoding HTML entities, since `$tagAttributes` are expected to be encoded already)
$finalTagAttributes = array_merge($tagAttributes, GeneralUtility::get_tag_attributes($finalTagParts['aTagParams']));
$finalTagAttributes = $this->addSecurityRelValues($finalTagAttributes, $target, $tagAttributes['href']);
$finalTagAttributes = $this->addSecurityRelValues($finalTagAttributes, $this->lastTypoLinkResult->getTarget(), $tagAttributes['href']);
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttributes($finalTagAttributes);
$finalAnchorTag = '<a ' . GeneralUtility::implodeAttributes($finalTagAttributes) . '>';

$this->lastTypoLinkTarget = $this->lastTypoLinkResult->getTarget();
// kept for backwards-compatibility in hooks
$finalTagParts['targetParams'] = !empty($tagAttributes['target']) ? ' target="' . $tagAttributes['target'] . '"' : '';
$this->lastTypoLinkTarget = $target;
$finalTagParts['targetParams'] = $this->lastTypoLinkResult->getTarget() ? 'target="' . htmlspecialchars($this->lastTypoLinkResult->getTarget()) . '"' : '';

// Call user function:
if ($conf['userFunc'] ?? false) {
$finalTagParts['TAG'] = $finalAnchorTag;
$finalAnchorTag = $this->callUserFunction($conf['userFunc'], $conf['userFunc.'] ?? [], $finalTagParts);
// Ensure to keep the result object up-to-date even after the user func was called
$finalAnchorTagParts = GeneralUtility::get_tag_attributes($finalAnchorTag, true);
$this->lastTypoLinkResult = $this->lastTypoLinkResult->withAttributes($finalAnchorTagParts, true);
}

// Hook: Call post processing function for link rendering:
$_params = [
'conf' => &$conf,
'linktxt' => &$linkText,
'finalTag' => &$finalAnchorTag,
'finalTagParts' => &$finalTagParts,
'linkDetails' => &$linkDetails,
'tagAttributes' => &$finalTagAttributes
];
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typoLink_PostProc'] ?? [] as $_funcRef) {
$ref = $this; // introduced for phpstan to not lose type information when passing $this into callUserFunction
GeneralUtility::callUserFunction($_funcRef, $_params, $ref);
if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typoLink_PostProc'])) {
$_params = [
'conf' => &$conf,
'linktxt' => &$linkText,
'finalTag' => &$finalAnchorTag,
'finalTagParts' => &$finalTagParts,
'linkDetails' => &$linkDetails,
'tagAttributes' => &$finalTagAttributes
];
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typoLink_PostProc'] ?? [] as $_funcRef) {
$ref = $this; // introduced for phpstan to not lose type information when passing $this into callUserFunction
GeneralUtility::callUserFunction($_funcRef, $_params, $ref);
}
// Ensure to keep the result object up-to-date even after the user func was called
$finalAnchorTagParts = GeneralUtility::get_tag_attributes($finalAnchorTag, true);
$this->lastTypoLinkResult = $this->lastTypoLinkResult
->withAttributes($finalAnchorTagParts)
->withLinkText((string)$_params['linktxt']);
}

// If flag "returnLastTypoLinkUrl" set, then just return the latest URL made:
Expand All @@ -4880,15 +4917,17 @@ public function typoLink($linkText, $conf)
return $this->lastTypoLinkUrl;
case 'target':
return $this->lastTypoLinkTarget;
case 'result':
return $this->lastTypoLinkResult;
}
}

$wrap = (string)$this->stdWrapValue('wrap', $conf ?? []);

if ($conf['ATagBeforeWrap'] ?? false) {
return $finalAnchorTag . $this->wrap($linkText, $wrap) . '</a>';
return $finalAnchorTag . $this->wrap((string)$this->lastTypoLinkResult->getLinkText(), $wrap) . '</a>';
}
return $this->wrap($finalAnchorTag . $linkText . '</a>', $wrap);
return $this->wrap($finalAnchorTag . $this->lastTypoLinkResult->getLinkText() . '</a>', $wrap);
}

protected function addSecurityRelValues(array $tagAttributes, ?string $target, string $url): array
Expand Down
Expand Up @@ -71,9 +71,9 @@ public function __construct(ContentObjectRenderer $contentObjectRenderer, TypoSc
* @param string $linkText the link text
* @param string $target the target to point to
* @param array $conf the TypoLink configuration array
* @return array an array with three parts (URL, Link Text, Target)
* @return array|LinkResultInterface an array with three parts (URL, Link Text, Target) - please note that in TYPO3 v12.0. this method will require to return a LinkResultInterface object
*/
abstract public function build(array &$linkDetails, string $linkText, string $target, array $conf): array;
abstract public function build(array &$linkDetails, string $linkText, string $target, array $conf);

/**
* Forces a given URL to be absolute.
Expand Down
Expand Up @@ -29,7 +29,7 @@ class DatabaseRecordLinkBuilder extends AbstractTypolinkBuilder
/**
* @inheritdoc
*/
public function build(array &$linkDetails, string $linkText, string $target, array $conf): array
public function build(array &$linkDetails, string $linkText, string $target, array $conf)
{
$tsfe = $this->getTypoScriptFrontendController();
$pageTsConfig = $tsfe->getPagesTSconfig();
Expand Down Expand Up @@ -88,6 +88,7 @@ public function build(array &$linkDetails, string $linkText, string $target, arr
$this->contentObjectRenderer->lastTypoLinkLD = $localContentObjectRenderer->lastTypoLinkLD;
$this->contentObjectRenderer->lastTypoLinkUrl = $localContentObjectRenderer->lastTypoLinkUrl;
$this->contentObjectRenderer->lastTypoLinkTarget = $localContentObjectRenderer->lastTypoLinkTarget;
$this->contentObjectRenderer->lastTypoLinkResult = $localContentObjectRenderer->lastTypoLinkResult;

// nasty workaround so typolink stops putting a link together, there is a link already built
throw new UnableToLinkException(
Expand Down
9 changes: 7 additions & 2 deletions typo3/sysext/frontend/Classes/Typolink/EmailLinkBuilder.php
Expand Up @@ -17,6 +17,8 @@

namespace TYPO3\CMS\Frontend\Typolink;

use TYPO3\CMS\Core\LinkHandling\LinkService;

/**
* Builds a TypoLink to an email address
*/
Expand All @@ -25,9 +27,12 @@ class EmailLinkBuilder extends AbstractTypolinkBuilder
/**
* @inheritdoc
*/
public function build(array &$linkDetails, string $linkText, string $target, array $conf): array
public function build(array &$linkDetails, string $linkText, string $target, array $conf): LinkResultInterface
{
[$url, $linkText] = $this->contentObjectRenderer->getMailTo($linkDetails['email'], $linkText);
return [$url, $linkText, $target];
return (new LinkResult(LinkService::TYPE_EMAIL, $url))
->withTarget($target)
->withLinkConfiguration($conf)
->withLinkText($linkText);
}
}
16 changes: 10 additions & 6 deletions typo3/sysext/frontend/Classes/Typolink/ExternalUrlLinkBuilder.php
Expand Up @@ -17,6 +17,7 @@

namespace TYPO3\CMS\Frontend\Typolink;

use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Frontend\Http\UrlProcessorInterface;

/**
Expand All @@ -27,12 +28,15 @@ class ExternalUrlLinkBuilder extends AbstractTypolinkBuilder
/**
* @inheritdoc
*/
public function build(array &$linkDetails, string $linkText, string $target, array $conf): array
public function build(array &$linkDetails, string $linkText, string $target, array $conf): LinkResultInterface
{
return [
$this->processUrl(UrlProcessorInterface::CONTEXT_EXTERNAL, $linkDetails['url'], $conf),
$this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']),
$target ?: $this->resolveTargetAttribute($conf, 'extTarget', true, $this->getTypoScriptFrontendController()->extTarget)
];
$url = $this->processUrl(UrlProcessorInterface::CONTEXT_EXTERNAL, $linkDetails['url'], $conf);
$linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']);
return (new LinkResult(LinkService::TYPE_URL, (string)$url))
->withLinkConfiguration($conf)
->withTarget(
$target ?: $this->resolveTargetAttribute($conf, 'extTarget', true, $this->getTypoScriptFrontendController()->extTarget),
)
->withLinkText($linkText);
}
}
Expand Up @@ -29,7 +29,7 @@ class FileOrFolderLinkBuilder extends AbstractTypolinkBuilder
/**
* @inheritdoc
*/
public function build(array &$linkDetails, string $linkText, string $target, array $conf): array
public function build(array &$linkDetails, string $linkText, string $target, array $conf): LinkResultInterface
{
$fileOrFolderObject = $linkDetails['file'] ?: $linkDetails['folder'];
// check if the file exists or if a / is contained (same check as in detectLinkType)
Expand Down Expand Up @@ -60,10 +60,8 @@ public function build(array &$linkDetails, string $linkText, string $target, arr
if (!empty($linkDetails['fragment'])) {
$url .= '#' . $linkDetails['fragment'];
}
return [
$this->forceAbsoluteUrl($url, $conf),
$linkText,
$target ?: $this->resolveTargetAttribute($conf, 'fileTarget', false, $tsfe->fileTarget)
];
return (new LinkResult($linkDetails['type'], $this->forceAbsoluteUrl($url, $conf)))
->withTarget($target ?: $this->resolveTargetAttribute($conf, 'fileTarget', false, $tsfe->fileTarget))
->withLinkText($linkText);
}
}

0 comments on commit 9ccd931

Please sign in to comment.