Enhance your route: generate better URLs with TYPO3

tl;dr > In projects up to TYPO3 8, individual pages could be excluded from the URL generation, e.g. via RealUrl or CoolURI. This was especially exciting for list/detail views, if the contents or page properties of list/detail pages differed outside the plugin.

With TYPO3 9LTS the URL routing concept has changed (much to the better!) and it is no longer possible to easily exclude single pages from the URL segment. A separate route enhancer solves this problem.

TYPO3 Route Enhancer: Improve your URL to generate SEO-friendly URLs and remove unnecessary url segments

A common concept with TYPO3 extensions is to output data records in list and detail views. In most cases this works fine if both views are configured on the same page in the TYPO3 page tree. Based on the parameters, the system recognizes which view of the data is desired and displays it. This case can easily be covered with the URL routing introduced in TYPO3 9LTS, even with search engine friendly URLs. 

It is not at all uncommon, however, to have the requirement that the list and detail view should be on different pages. For example, additional content elements should be displayed on the overview page. This is usually no problem either, if the TYPO3 page tree is structured accordingly. Often seen and often configured by me, it looks something like this: 

TYPO3 page tree with news configuration

For this blog post, I'll treat the problem exemplarily with the news extension by Georg Ringer. Probably every TYPO3 developer and integrator has used it before. But the principle is the same for every Extbase-based extension and can easily be adapted. And also for extensions that do not use Extbase, this solution can be used accordingly (only the last part, the configuration of the slugs works a bit different then).

With the usual instructions, e.g. the one in the news extension itself, TYPO3 now generates talking URLs, e.g.

example.org/category1 for the list view and 

example.org/category1/detail/title-of-news for a detailed view on a specific article.

Especially if you care about SEO, the segment /detail/ in the URL is superfluous and unwanted.

The challenge now is to teach TYPO3 to generate the URLs as desired and resolve them accordingly. 

URL Generation

With most extensions the generation of the desired URLs is relatively simple, so also with news. Instead of the page Detail [PID: 58] in our example, we generate the links for the detail view simply on the page News [PID: 57]. If you only have such a news subtree, you can simply define the PID in the configuration. If there are several such subtrees (e.g. /category1/detail, category2/detail etc.) it helps to link from the list view to the own page:

 

plugin.tx_news {
  settings {
    defaultDetailPid = 0
    detailPidDetermination = flexform, default
  }
}

 

This leads to the URLs being generated correctly (example.org/category1/title-of-news), but at this point the news detail view no longer works.

AND REVERSE URL RESOLUTION

For this purpose, a separate enhancer is inserted, which takes over the reverse resolution of the URLs and exchanges the stored PageID with the desired one.

We register this in the ext_localconf.php:

 

$GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers']['NewsEnhancer'] = \MarcWillmann\Sitepackage\Routing\NewsEnhancer::class;

 

and implement it (sitepackage/Classes/Routing/NewsEnhancer.php).

Here, in addition to the standard implementation in the parameter list, the system checks whether a detail view should be displayed and, if so, overwrites the $page['uid'] to be displayed. Where the PID that we ultimately want to use comes from is relatively irrelevant - it can be a value maintained in the corresponding news category, always the first subpage (if we know that the detail page is always below the list view), configured in TypoScript or whatever. 

In the concrete example I get the PID from the news category. If there is no value there, there is a fallback to the first subpage. 

As a bonus, we can also check at this point whether the news was called with the correct URL maintained in the category and if not, redirect - this prevents duplicate content! 

Here, in addition to the standard implementation in the parameter list, the system checks whether a detail view should be displayed and, if so, overwrites the $page['uid'] to be displayed.

 

<?php
declare(strict_types=1);

namespace MarcWillmann\Sitepackage\Routing;

use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Extbase\Routing\ExtbasePluginEnhancer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;

use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManager;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;

class NewsEnhancer extends ExtbasePluginEnhancer
{

    public function __construct(array $configuration)
    {
        parent::__construct($configuration);

        return;
    }

    public function buildResult(
        Route $route,
        array $results,
        array $remainingQueryParameters = []
    ): PageArguments {
        $variableProcessor = $this->getVariableProcessor();
        // determine those parameters that have been processed
        $parameters = array_intersect_key(
            $results,
            array_flip($route->compile()->getPathVariables())
        );

        // check, if parameters contains detail view for news
        if (array_key_exists('tx_news_pi1__news', $parameters)) {
            $newsId = intval($parameters['tx_news_pi1__news']);
        }
        // strip of those that where not processed (internals like _route, etc.)
        $internals = array_diff_key($results, $parameters);
        $matchedVariableNames = array_keys($parameters);

        $staticMappers
            = $route->filterAspects(
                [StaticMappableAspectInterface::class],
                $matchedVariableNames
            );
        $dynamicCandidates = array_diff_key($parameters, $staticMappers);

        // all route arguments
        $routeArguments = $this->inflateParameters($parameters, $internals);
        // dynamic arguments, that don't have a static mapper
        $dynamicArguments = $variableProcessor
            ->inflateNamespaceParameters($dynamicCandidates, $this->namespace);
        // static arguments, that don't appear in dynamic arguments
        $staticArguments
            = ArrayUtility::arrayDiffAssocRecursive(
                $routeArguments,
                $dynamicArguments
            );
        // inflate remaining query arguments that could not be applied to the route
        $remainingQueryParameters = $variableProcessor
            ->inflateNamespaceParameters(
                $remainingQueryParameters,
                $this->namespace
            );

        $page = $route->getOption('_page');
        $requestedPage = $page['l10n_parent'] > 0 ? $page['l10n_parent']
            : $page['uid'];

        if ($newsId > 0) {
            $pageId = 0;

            /* try to catch singlePid from news category */
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
                ->getQueryBuilderForTable('tx_news_domain_model_news');
            $statement = $queryBuilder
                ->select('c.single_pid')
                ->from('tx_news_domain_model_news')
                ->leftJoin(
                    'tx_news_domain_model_news',
                    'sys_category_record_mm',
                    'mm',
                    $queryBuilder->expr()->eq(
                        'mm.uid_foreign',
                        $queryBuilder->quoteIdentifier('tx_news_domain_model_news.uid')
                    )
                )
                ->leftJoin(
                    'mm',
                    'sys_category',
                    'c',
                    $queryBuilder->expr()->eq(
                        'mm.uid_local',
                        $queryBuilder->quoteIdentifier('c.uid')
                    )
                )
                ->where(
                    $queryBuilder->expr()->eq(
                        'tx_news_domain_model_news.uid',
                        $queryBuilder->createNamedParameter(
                            $newsId,
                            \PDO::PARAM_INT
                        )
                    )
                )
                ->execute();

            while ($row = $statement->fetch()) {
                $pageId = $row['single_pid'];
            }

            /* find parent page */
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
                ->getQueryBuilderForTable('pages');
            $statement = $queryBuilder
                ->select('pid')
                ->from('pages')
                ->where(
                    $queryBuilder->expr()->eq(
                        'uid',
                        $queryBuilder->createNamedParameter(
                            $pageId,
                            \PDO::PARAM_INT
                        )
                    )
                )
                ->execute();

            while ($row = $statement->fetch()) {
                $parentPage = $row['pid'];
            }

            /**
             * Expectation is:
             * $parentPage == $requestedPage.
             *
             * If it's not, redirect request
             */
            if ($parentPage != $requestedPage) {
                $GLOBALS['TSFE']
                    = GeneralUtility::makeInstance(
                        'TYPO3\\CMS\\Frontend\\Controller\\TypoScriptFrontendController',
                        $GLOBALS['TYPO3_CONF_VARS'],
                        $parentPage,
                        $type
                    );
                $GLOBALS['TSFE']->connectToDB();
                $GLOBALS['TSFE']->initFEuser();
                $GLOBALS['TSFE']->determineId();
                $GLOBALS['TSFE']->initTemplate();
                $GLOBALS['TSFE']->getConfigArray();

                $objectManager
                    = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance('TYPO3\\CMS\Extbase\\Object\\ObjectManager');

                $configurationManager
                    = $objectManager->get(ConfigurationManager::class);
                /**
                 * @var ContentObjectRenderer $contentObjectRenderer
                 */
                $contentObjectRenderer
                    = $objectManager->get(ContentObjectRenderer::class);

                $configurationManager->setContentObject($contentObjectRenderer);
                $uriBuilder = $objectManager->get(UriBuilder::class);
                $uriBuilder->injectConfigurationManager($configurationManager);
                $uriBuilder->setTargetPageUid($parentPage)
                    ->uriFor(
                        'detail',
                        ['news' => $newsId],
                        'News',
                        'news',
                        'pi1'
                    );

                $url = $uriBuilder->buildFrontendUri();


                header("HTTP/1.1 301 Moved Permanently");
                header("Location: $url");
                exit();
            }

            /* Fallback to first child page if no category page is found */
            if ($pageId === 0) {
                $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent']
                    : $page['uid']);
                $pageId = $this->findFirstChildPage($pageId);
            }

            $page['uid'] = $pageId;
            $page['l10n_parent'] = $pageId;
        }

        $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent']
            : $page['uid']);
        $type = $this->resolveType($route, $remainingQueryParameters);

        return new PageArguments(
            $pageId,
            $type,
            $routeArguments,
            $staticArguments,
            $remainingQueryParameters
        );
    }

    function findFirstChildPage($parent = 0)
    {
        $depth = 1;
        $queryGenerator
            = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance('TYPO3\\CMS\\Core\\Database\\QueryGenerator');
        $childPids = $queryGenerator->getTreeList(
            $parent,
            $depth,
            0,
            1
        ); //Will be a string like 1,2,3
        $childPids = explode(',', (string)$childPids);

        foreach ($childPids as $child) {
            if ($child != $parent) {
                return $child;
            }
        }

        return $parent;
    }
}

 

 

Almost there

Last but not least, the NewsEnhancer has to be configured in the sites configuration; since it is derived from ExtbasePlugin-Enhancer, this is very easy:

web/app/config/sites/whatever/config.yaml

 

News:
    type: NewsEnhancer
    extension: News
    plugin: Pi1
    routes:
      - routePath: '/{news-title}'
        _controller: 'News::detail'
        _arguments:
          news-title: news
    aspects:
      news-title:
        type: PersistedAliasMapper
        tableName: tx_news_domain_model_news
        routeFieldName: path_segment

 

 

I know that!

The Self Verification Button allows you to measure your own learning progress and compare it with the requirements for official TYPO3 certifications. This functionality is provided by SkillDisplay, registration and use are free for learners. Find out more about SkillDisplay for learners..

 

Comments (0)

No comments found!

Write new comment reply