Route Enhancer

tl;dr > In Projekten bis TYPO3 8 konnten z.B. per RealUrl oder CoolURI einzelne Seiten aus der URL-Generierung ausgeschlossen werden. Dies war insbesondere für List/Detail-Ansichten spannend, wenn sich die Inhalte oder Seiteneigenschaften von Listen- und Detail-Seiten auch außerhalb des Plugins unterschieden haben.

Mit TYPO3 9LTS hat sich das URL-Routing-Konzept geändert (deutlich zum besseren!) und es ist nicht mehr ohne weiteres möglich, einzelne Seiten aus dem URL-Segment auszunehmen. Ein eigener Route Enhancer löst dieses Problem.

TYPO3 Route Enhancer: URL-Segmente unter Kontrolle

Ein gängiges Konzept bei TYPO3-Extensions ist es, Datensätze in Listen- und Detailansichten auszugeben. Dies funktioniert in den meisten Fällen prima, wenn beide Ansichten auf derselben Seite im TYPO3-Seitenbaum konfiguriert sind. Anhand der Parameter erkennt das System, welche Sicht auf die Daten gewünscht ist und zeigt diese an. Dieser Fall lässt sich mit dem in TYPO3 9LTS eingeführten URL-Routing auch mit suchmaschinenfreundlichen URLs problemlos abdecken. 

Gar nicht so selten gibt es aber die Anforderung, dass die Listen- und Detailansicht auf verschiedenen Seiten sein soll. So sollen z.B. auf der Übersichtsseite zusätzlich noch weitere Inhaltselemente ausgespielt werden. Auch dies ist in der Regel kein Problem, wenn man den TYPO3-Seitenbaum entsprechend strukturiert. Häufig sieht das dann ungefähr so aus: 

TYPO3 Seitenbaum mit News-Konfiguration

Für diesen Blog-Beitrag behandele ich das Problem exemplarisch mit der News-Extension von Georg Ringer. Diese ist vermutlich jedem TYPO3-Entwickler und -Integrator vertraut. Ganz genauso funktioniert es aber auch bei jeder andere Extbase-basierten Extension. Und auch für Extensions, die nicht Extbase verwenden, kann diese Lösung entsprechend verwendet werden (lediglich der letzte Part, die Konfiguration der Slugs funktioniert dann etwas anders).

Mit den gängigen Anleitungen, z.B. der in der news-Extension selbst generiert TYPO3 nun sprechende URLs, z.B.

example.org/category1 für die Listenansicht und 

example.org/category1/detail/titel-der-news für eine Detailansicht auf einen bestimmten Artikel.

Insbesondere wenn man Wert auf SEO legt, ist das Segment /detail/ in der URL überflüssig und unerwünscht.

Die Herausforderung besteht nun darin, TYPO3 beizubringen, die URLs wie gewünscht zu generieren und entsprechend wieder aufzulösen. 

URL-Generierung

Bei den meisten Extensions ist die Generierung der gewünschten URLs relativ einfach, so auch bei News. Statt in unserem Beispiel auf die Seite Detail [PID: 58] generieren wir die Links für die Detailansicht einfach auf die Seite News [PID: 57]. Wenn man nur einen solchen News-Teilbaum hat, kann man die PID in der Konfiguration einfach fest hinterlegen. Gibt es mehrere solche Teilbäume (z.B. /category1/detail, category2/detail usw.) hilft es, von der Listenansicht auf die jeweils eigene Seite zu verweisen:

 

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

 

Das führt dazu, dass die URLs schon mal korrekt generiert werden (example.org/category1/titel-der-news), allerdings funktioniert zu diesem Zeitpunkt der News-Detail-View nicht mehr. 

Und Rückwärtsauflösung der URL

Hierfür wird ein eigener Enhancer eingefügt, der die Rückwärtsauflösung der URLs übernimmt und die hinterlegte PageID mit der gewünschten austauscht.

In der ext_localconf.php wird dieser angemeldet:

 

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

 

und dann natürlich auch implementiert (in unserem Beispiel in sitepackage/Classes/Routing/NewsEnhancer.php

Hier wird neben der Standardimplementierung in der Parameterliste überprüft, ob ein Detail-View angezeigt werden soll und falls ja, die anzuzeigende $page['uid'] überschrieben. Woher die PID kommt, die wir letztlich verwenden wollen, ist relativ egal - das kann ein Wert, der in der entsprechenden News-Kategorie gepflegt ist, sein, immer die erste Unterseite (wenn wir wissen, dass die Detail-Seite immer unterhalb der Listenansicht ist), im TypoScript konfiguriert oder was auch immer. 

In dem konkreten Beispiel hole ich die PID aus der News-Kategorie. Sofern dort kein Wert vorhanden ist, gibt es einen Fallback auf die erste Unterseite. 

Als Bonus können wir an dieser Stelle auch prüfen, ob die News mit der korrekten in der Kategorie gepflegten URL aufgerufen wurde und falls nicht einen Redirect machen - das verhindert Duplicate Content! 

Vorteil: Dies alles passiert, bevor TYPO3 mit dem Rendern der Seite beginnt, d.h. wir rendern tatsächlich die gewünschte Artikel-Seite mit allen Einstellungen und Conditions, die ggf. zum Tragen kommen.

 

<?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;
    }
}

 

 

Fast am Ziel

Zu guter Letzt muss in der Sites-Configuration der NewsEnhancer auch noch konfiguriert werden; da er von ExtbasePlugin-Enhancer ableitet, ist das sehr einfach:

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

 

 

Ich kann das!

Der Self Verification Button ermöglicht Dir, Deinen eigenen Lernfortschritt zu messen und mit der Anforderung für offizielle TYPO3 Zertifizierungen zu vergleichen. Diese Funktionalität wird von SkillDisplay bereitgestellt, Registrierung und Nutzung sind für Lernende kostenfrei. Finde mehr über SkillDisplay für Lernende heraus.

 

Kommentare (3)

  • Robert Radony
    Robert Radony
    am 16.03.2020
    Vielen Dank für den Artikel. genau das suche ich für mein aktuelles Projekt!
  • Tobias Liebig
    Tobias Liebig
    am 17.11.2021
    Hej Marc,

    vielen Dank für den Artikel. Ich habe diesen in einem Projekt als Inspiration genutzt und einen eigenen, speziellen NewsEnhancer implementiert. Nun ist in dem Projekt ein Bug aufgefallen der mit Deinem NewsEnhancer auch existieren dürfte.

    In Deinem NewsEnhancer in findFirstChildPage holst Du mit queryGenerator->getTreeList die Child-Pages und iterierst über diese. Den ersten Treffer, der nicht die Parent-Page ist, gibst Du als Ergebnis zurück.
    Nun gibt aber getTreeList die Seiten-UIDs NICHT in der Reihenfolge des Seitenbaums, sondern sortiert nach UIDs zurück.
    D.h. die findFirstChildPage findet nicht die erste Unterseite, sondern die Unterseite mit der kleinsten UID.

    Das passte in meinem Projekt zufällig ganz gut, weil es entweder nur eine Unterseite gab, oder die Detailseite auch die kleinste UID hat. Aber es gab eben auch einen Fall, wo das nicht passte.

    Meine findFirstChildPageUid sieht nun so aus:

    ```
    /**
    * @param int $parentPageUid
    * @return int
    */
    protected function findFirstChildPageUid($parentPageUid = 0): int
    {
    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
    ->getQueryBuilderForTable('pages');
    $queryBuilder->getRestrictions()
    ->removeAll()
    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
    $queryBuilder->select('uid')
    ->from('pages')
    ->where(
    $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($parentPageUid, \PDO::PARAM_INT)),
    $queryBuilder->expr()->eq('sys_language_uid', 0)
    )
    ->setMaxResults(1)
    ->orderBy('sorting');
    return (int)$queryBuilder->execute()->fetchOne();
    }
    ```

    Liebe Grüße
    Tobias
  • Alex
    Alex
    am 22.12.2022
    Sehr interessant. Werde ich mal versuchen zu adaptieren. Denn ich muss gerade speaking uris für Datensätze, die ich via externer API erhalte generieren. Da könnte dies die Lösung für mein Problem sein.

Neue Antwort auf Kommentar schreiben