[FEATURE] Introduce PageTypeEnhancer 78/58478/14
authorBenni Mack <benni@typo3.org>
Sat, 29 Sep 2018 22:27:04 +0000 (00:27 +0200)
committerFrank Naegler <frank.naegler@typo3.org>
Sun, 30 Sep 2018 22:45:54 +0000 (00:45 +0200)
A new PageTypeEnhancer is added to map the internal TYPO3
GET parameter `type` to a speaking URL suffix.

The PageTypeEnhancer only does suffixes, if other

You can configure it like this:

routeEnhancers:
  PageType:
    type: PageType
    default: ''
    map:
      '.html': 1
      'menu.json': 13

Resolves: #86160
Releases: master
Change-Id: Ie4b387963012946272568a69c9581e8d345dbe4c
Reviewed-on: https://review.typo3.org/58478
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Frank Naegler <frank.naegler@typo3.org>
Tested-by: Frank Naegler <frank.naegler@typo3.org>
15 files changed:
typo3/sysext/core/Classes/Routing/Enhancer/AbstractEnhancer.php
typo3/sysext/core/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php [new file with mode: 0644]
typo3/sysext/core/Classes/Routing/Enhancer/EnhancerInterface.php
typo3/sysext/core/Classes/Routing/Enhancer/PageTypeDecorator.php [new file with mode: 0644]
typo3/sysext/core/Classes/Routing/Enhancer/PluginEnhancer.php
typo3/sysext/core/Classes/Routing/Enhancer/RoutingEnhancerInterface.php [new file with mode: 0644]
typo3/sysext/core/Classes/Routing/Enhancer/SimpleEnhancer.php
typo3/sysext/core/Classes/Routing/PageArguments.php
typo3/sysext/core/Classes/Routing/PageRouter.php
typo3/sysext/core/Classes/Routing/Route.php
typo3/sysext/core/Configuration/DefaultConfiguration.php
typo3/sysext/core/Documentation/Changelog/master/Feature-86160-PageTypeEnhancerForMappingTypeParameter.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Routing/PageRouterTest.php
typo3/sysext/frontend/Classes/Middleware/PageResolver.php
typo3/sysext/frontend/Tests/Unit/Middleware/PageResolverTest.php

index 30159da..b400ffe 100644 (file)
@@ -76,6 +76,28 @@ abstract class AbstractEnhancer implements EnhancerInterface
     }
 
     /**
+     * Retrieves type from processed route and modifies remaining query parameters.
+     *
+     * @param Route $route
+     * @param array $remainingQueryParameters reference to remaining query parameters
+     * @return string
+     */
+    protected function resolveType(Route $route, array &$remainingQueryParameters): string
+    {
+        $decoratedParameters = $route->getOption('_decoratedParameters');
+        if (!isset($decoratedParameters['type'])) {
+            return '0';
+        }
+        $type = (string)$decoratedParameters['type'];
+        unset($decoratedParameters['type']);
+        $remainingQueryParameters = array_replace_recursive(
+            $remainingQueryParameters,
+            $decoratedParameters
+        );
+        return $type;
+    }
+
+    /**
      * @return VariableProcessor
      */
     protected function getVariableProcessor(): VariableProcessor
@@ -89,7 +111,7 @@ abstract class AbstractEnhancer implements EnhancerInterface
     /**
      * {@inheritdoc}
      */
-    public function setAspects(array $aspects)
+    public function setAspects(array $aspects): void
     {
         $this->aspects = $aspects;
     }
diff --git a/typo3/sysext/core/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php b/typo3/sysext/core/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php
new file mode 100644 (file)
index 0000000..3574c79
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Routing\Enhancer;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Routing\RouteCollection;
+
+/**
+ * Decorates a route (or routes within a collection) with additional parameters.
+ */
+interface DecoratingEnhancerInterface extends EnhancerInterface
+{
+    /**
+     * Decorates route collection and modifies route parameters and the
+     * URL path to be processed during URL resolving. Executed before invoking
+     * routing enhancers.
+     *
+     * @param RouteCollection $collection
+     * @param array $parameters reference to reconstituted parameters
+     * @param string $routePath reference to URL path
+     */
+    public function decorateForMatching(RouteCollection $collection, array &$parameters, string &$routePath): void;
+
+    /**
+     * Decorates route collection and modifies route parameters during URL
+     * URL generation. Executed before invoking routing enhancers.
+     *
+     * @param RouteCollection $collection
+     * @param array $parameters reference to query parameters
+     */
+    public function decorateForGeneration(RouteCollection $collection, array &$parameters): void;
+}
index fd57c13..5679b45 100644 (file)
@@ -17,34 +17,17 @@ namespace TYPO3\CMS\Core\Routing\Enhancer;
  */
 
 use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
-use TYPO3\CMS\Core\Routing\RouteCollection;
 
 /**
- * Interface for enhancers
+ * Base interface for enhancers, which can be decorators for adding parameters,
+ * or routing enhancers which adds variants to a page.
  */
 interface EnhancerInterface
 {
     /**
-     * Extends route collection with all routes. Used during URL resolving.
-     *
-     * @param RouteCollection $collection
-     */
-    public function enhanceForMatching(RouteCollection $collection): void;
-
-    /**
-     * Extends route collection with routes that are relevant for given
-     * parameters. Used during URL generation.
-     *
-     * @param RouteCollection $collection
-     * @param array $parameters
-     */
-    public function enhanceForGeneration(RouteCollection $collection, array $parameters): void;
-
-    /**
      * @param AspectInterface[] $aspects
-     * @return mixed
      */
-    public function setAspects(array $aspects);
+    public function setAspects(array $aspects): void;
 
     /**
      * @return AspectInterface[]
diff --git a/typo3/sysext/core/Classes/Routing/Enhancer/PageTypeDecorator.php b/typo3/sysext/core/Classes/Routing/Enhancer/PageTypeDecorator.php
new file mode 100644 (file)
index 0000000..1d539fc
--- /dev/null
@@ -0,0 +1,192 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Routing\Enhancer;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Routing\Route;
+use TYPO3\CMS\Core\Routing\RouteCollection;
+
+/**
+ * Resolves a static list (like page.typeNum) against a file pattern. Usually added on the very last part
+ * of the URL.
+ * It is important that the PageType Enhancer is executed at the very end in your configuration, as it modifies
+ * EXISTING route variants.
+ *
+ * routeEnhancers:
+ *   PageTypeSuffix:
+ *     type: PageType
+ *     default: ''
+ *     map:
+ *       '.html': 1
+ *       'menu.json': 13
+ */
+class PageTypeDecorator extends AbstractEnhancer implements DecoratingEnhancerInterface
+{
+    protected const PREFIXES = ['.', '-', '_'];
+
+    /**
+     * @var array
+     */
+    protected $configuration;
+
+    /**
+     * @var string
+     */
+    protected $default;
+
+    /**
+     * @var array
+     */
+    protected $map;
+
+    /**
+     * @param array $configuration
+     */
+    public function __construct(array $configuration)
+    {
+        $default = $configuration['default'] ?? '';
+        $map = $configuration['map'] ?? null;
+
+        if (!is_string($default)) {
+            throw new \InvalidArgumentException('default must be string', 1538327508);
+        }
+        if (!is_array($map)) {
+            throw new \InvalidArgumentException('map must be array', 1538327509);
+        }
+
+        $this->configuration = $configuration;
+        $this->default = $default;
+        $this->map = array_map('strval', $map);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function decorateForMatching(RouteCollection $collection, array &$parameters, string &$routePath): void
+    {
+        $pattern = $this->buildRegularExpressionPattern();
+        if (!preg_match('#' . $pattern . '#', $routePath, $matches, PREG_UNMATCHED_AS_NULL)) {
+            $parameters['type'] = 0;
+            return;
+        }
+
+        $value = $matches['slashedItems'] ?? $matches['regularItems'] ?? null;
+        if (!is_string($value)) {
+            throw new \UnexpectedValueException(
+                'Unexpected null value at end of URL',
+                1538335671
+            );
+        }
+
+        $parameters['type'] = $this->map[$value] ?? 0;
+        $valuePattern = $this->quoteForRegularExpressionPattern($value) . '$';
+        $routePath = preg_replace('#' . $valuePattern . '#', '', $routePath);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function decorateForGeneration(RouteCollection $collection, array &$parameters): void
+    {
+        $type = isset($parameters['type']) ? (string)$parameters['type'] : null;
+        $value = $this->resolveValue($type);
+        unset($parameters['type']);
+
+        if ($value !== '' && !in_array($value{0}, static::PREFIXES)) {
+            $value = '/' . $value;
+        }
+
+        /**
+         * @var string $routeName
+         * @var Route $existingRoute
+         */
+        foreach ($collection->all() as $routeName => $existingRoute) {
+            $existingRoute->setPath(rtrim($existingRoute->getPath(), '/') . $value);
+            $deflatedParameters = $existingRoute->getOption('deflatedParameters');
+            if (isset($deflatedParameters['type'])) {
+                unset($deflatedParameters['type']);
+                $existingRoute->setOption(
+                    'deflatedParameters',
+                    $deflatedParameters
+                );
+            }
+        }
+    }
+
+    /**
+     * Checks if the value exists inside the map.
+     *
+     * @param string|null $type
+     * @return string
+     */
+    protected function resolveValue(?string $type): string
+    {
+        $index = array_search($type, $this->map, true);
+        if ($index !== false) {
+            return $index;
+        }
+        return $this->default;
+    }
+
+    /**
+     * Builds a regexp out of the map.
+     * @return string
+     */
+    protected function buildRegularExpressionPattern(): string
+    {
+        $items = array_keys($this->map);
+        $slashedItems = array_filter($items, [$this, 'needsSlashPrefix']);
+        $regularItems = array_diff($items, $slashedItems);
+
+        $slashedItems = array_map([$this, 'quoteForRegularExpressionPattern'], $slashedItems);
+        $regularItems = array_map([$this, 'quoteForRegularExpressionPattern'], $regularItems);
+
+        $patterns = [];
+        if (!empty($slashedItems)) {
+            $patterns[] = '/(?P<slashedItems>' . implode('|', $slashedItems) . ')';
+        }
+        if (!empty($regularItems)) {
+            $patterns[] = '(?P<regularItems>' . implode('|', $regularItems) . ')';
+        }
+        return '(?:' . implode('|', $patterns) . ')$';
+    }
+
+    /**
+     * Helper method for regexps.
+     *
+     * @param string $value
+     * @return string
+     */
+    protected function quoteForRegularExpressionPattern(string $value): string
+    {
+        return preg_quote($value, '#');
+    }
+
+    /**
+     * Checks if a slash should be prefixed.
+     *
+     * @param string $value
+     * @return bool
+     */
+    protected function needsSlashPrefix(string $value): bool
+    {
+        return !in_array(
+            $value{0} ?? '',
+            static::PREFIXES,
+            true
+        );
+    }
+}
index b6cbd72..838e348 100644 (file)
@@ -38,7 +38,7 @@ use TYPO3\CMS\Core\Utility\ArrayUtility;
  *       user_id: '[a-z]+'
  *       hash: '[a-z]{0-6}'
  */
-class PluginEnhancer extends AbstractEnhancer implements ResultingInterface
+class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, ResultingInterface
 {
     /**
      * @var array
@@ -87,7 +87,8 @@ class PluginEnhancer extends AbstractEnhancer implements ResultingInterface
 
         $page = $route->getOption('_page');
         $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $page['uid']);
-        return new PageArguments($pageId, $routeArguments, $staticArguments, $remainingQueryParameters);
+        $type = $this->resolveType($route, $remainingQueryParameters);
+        return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
     }
 
     /**
diff --git a/typo3/sysext/core/Classes/Routing/Enhancer/RoutingEnhancerInterface.php b/typo3/sysext/core/Classes/Routing/Enhancer/RoutingEnhancerInterface.php
new file mode 100644 (file)
index 0000000..13a5a66
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Routing\Enhancer;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Routing\RouteCollection;
+
+/**
+ * Interface for enhancers
+ */
+interface RoutingEnhancerInterface extends EnhancerInterface
+{
+    /**
+     * Extends route collection with all routes. Used during URL resolving.
+     *
+     * @param RouteCollection $collection
+     */
+    public function enhanceForMatching(RouteCollection $collection): void;
+
+    /**
+     * Extends route collection with routes that are relevant for given
+     * parameters. Used during URL generation.
+     *
+     * @param RouteCollection $collection
+     * @param array $parameters
+     */
+    public function enhanceForGeneration(RouteCollection $collection, array $parameters): void;
+}
index d87ca6f..c87ead7 100644 (file)
@@ -33,7 +33,7 @@ use TYPO3\CMS\Core\Utility\ArrayUtility;
  *       category_id: 'category/id'
  *       scope_id: 'scope/id'
  */
-class SimpleEnhancer extends AbstractEnhancer implements ResultingInterface
+class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, ResultingInterface
 {
     /**
      * @var array
@@ -75,7 +75,8 @@ class SimpleEnhancer extends AbstractEnhancer implements ResultingInterface
 
         $page = $route->getOption('_page');
         $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $page['uid']);
-        return new PageArguments($pageId, $routeArguments, $staticArguments, $remainingQueryParameters);
+        $type = $this->resolveType($route, $remainingQueryParameters);
+        return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
     }
 
     /**
index 996f4f6..3cda26b 100644 (file)
@@ -29,6 +29,11 @@ class PageArguments implements RouteResultInterface
     protected $pageId;
 
     /**
+     * @var string
+     */
+    protected $pageType;
+
+    /**
      * @var array
      */
     protected $arguments;
@@ -60,13 +65,15 @@ class PageArguments implements RouteResultInterface
 
     /**
      * @param int $pageId
+     * @param string $pageType
      * @param array $routeArguments
      * @param array $staticArguments
      * @param array $remainingArguments
      */
-    public function __construct(int $pageId, array $routeArguments, array $staticArguments = [], array $remainingArguments = [])
+    public function __construct(int $pageId, string $pageType, array $routeArguments, array $staticArguments = [], array $remainingArguments = [])
     {
         $this->pageId = $pageId;
+        $this->pageType = $pageType;
         $this->routeArguments = $this->sort($routeArguments);
         $this->staticArguments = $this->sort($staticArguments);
         $this->arguments = $this->routeArguments;
@@ -101,6 +108,14 @@ class PageArguments implements RouteResultInterface
     }
 
     /**
+     * @return string
+     */
+    public function getPageType(): string
+    {
+        return $this->pageType;
+    }
+
+    /**
      * @param string $name
      * @return mixed|null
      */
@@ -250,7 +265,7 @@ class PageArguments implements RouteResultInterface
      */
     public function offsetExists($offset): bool
     {
-        return $offset === 'pageId' || isset($this->arguments[$offset]);
+        return $offset === 'pageId' || $offset === 'pageType' || isset($this->arguments[$offset]);
     }
 
     /**
@@ -262,6 +277,9 @@ class PageArguments implements RouteResultInterface
         if ($offset === 'pageId') {
             return $this->getPageId();
         }
+        if ($offset === 'pageType') {
+            return $this->getPageType();
+        }
         return $this->arguments[$offset] ?? null;
     }
 
index 1313c2e..b3dabed 100644 (file)
@@ -32,9 +32,11 @@ use TYPO3\CMS\Core\Http\Uri;
 use TYPO3\CMS\Core\Routing\Aspect\AspectFactory;
 use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
 use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
+use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
 use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
 use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface;
 use TYPO3\CMS\Core\Routing\Enhancer\ResultingInterface;
+use TYPO3\CMS\Core\Routing\Enhancer\RoutingEnhancerInterface;
 use TYPO3\CMS\Core\Site\Entity\Site;
 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -109,7 +111,8 @@ class PageRouter implements RouterInterface
      */
     public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): ?RouteResultInterface
     {
-        $slugCandidates = $this->getCandidateSlugsFromRoutePath($previousResult->getTail() ?: '/');
+        $urlPath = $previousResult->getTail();
+        $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/');
         $language = $previousResult->getLanguage();
         $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
         // Stop if there are no candidates
@@ -117,6 +120,7 @@ class PageRouter implements RouterInterface
             return null;
         }
 
+        $decoratedParameters = [];
         $fullCollection = new RouteCollection();
         foreach ($pageCandidates ?? [] as $page) {
             $pageIdForDefaultLanguage = (int)($page['l10n_parent'] ?: $page['uid']);
@@ -129,8 +133,16 @@ class PageRouter implements RouterInterface
                 ['utf8' => true, '_page' => $page]
             );
             $pageCollection->add('default', $defaultRouteForPage);
-            foreach ($this->getEnhancersForPage($pageIdForDefaultLanguage, $language) as $enhancer) {
-                $enhancer->enhanceForMatching($pageCollection);
+            $enhancers = $this->getEnhancersForPage($pageIdForDefaultLanguage, $language);
+            foreach ($enhancers as $enhancer) {
+                if ($enhancer instanceof DecoratingEnhancerInterface) {
+                    $enhancer->decorateForMatching($pageCollection, $decoratedParameters, $urlPath);
+                }
+            }
+            foreach ($enhancers as $enhancer) {
+                if ($enhancer instanceof RoutingEnhancerInterface) {
+                    $enhancer->enhanceForMatching($pageCollection);
+                }
             }
 
             $pageCollection->addNamePrefix('page_' . $page['uid'] . '_');
@@ -139,9 +151,10 @@ class PageRouter implements RouterInterface
 
         $matcher = new PageUriMatcher($fullCollection);
         try {
-            $result = $matcher->match('/' . trim($previousResult->getTail(), '/'));
+            $result = $matcher->match('/' . trim($urlPath, '/'));
             /** @var Route $matchedRoute */
             $matchedRoute = $fullCollection->get($result['_route']);
+            $matchedRoute->setOption('_decoratedParameters', $decoratedParameters);
             return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
         } catch (ResourceNotFoundException $e) {
             // return nothing
@@ -197,8 +210,16 @@ class PageRouter implements RouterInterface
 
         // cHash is never considered because cHash is built by this very method.
         unset($originalParameters['cHash']);
-        foreach ($this->getEnhancersForPage($pageId, $language) as $enhancer) {
-            $enhancer->enhanceForGeneration($collection, $originalParameters);
+        $enhancers = $this->getEnhancersForPage($pageId, $language);
+        foreach ($enhancers as $enhancer) {
+            if ($enhancer instanceof RoutingEnhancerInterface) {
+                $enhancer->enhanceForGeneration($collection, $originalParameters);
+            }
+        }
+        foreach ($enhancers as $enhancer) {
+            if ($enhancer instanceof DecoratingEnhancerInterface) {
+                $enhancer->decorateForGeneration($collection, $originalParameters);
+            }
         }
 
         $scheme = $language->getBase()->getScheme();
@@ -264,6 +285,7 @@ class PageRouter implements RouterInterface
         if ($fragment) {
             $uri = $uri->withFragment($fragment);
         }
+        // @todo Throw exception in case $uri is null
         return $uri;
     }
 
@@ -321,10 +343,11 @@ class PageRouter implements RouterInterface
      *
      * @param int $pageId
      * @param SiteLanguage $language
-     * @return \Generator|EnhancerInterface[]
+     * @return EnhancerInterface[]
      */
-    protected function getEnhancersForPage(int $pageId, SiteLanguage $language): \Generator
+    protected function getEnhancersForPage(int $pageId, SiteLanguage $language): array
     {
+        $enhancers = [];
         foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
             // Check if there is a restriction to page Ids.
             if (is_array($enhancerConfiguration['limitToPages'] ?? null) && !in_array($pageId, $enhancerConfiguration['limitToPages'])) {
@@ -340,15 +363,17 @@ class PageRouter implements RouterInterface
                 );
                 $enhancer->setAspects($aspects);
             }
-            yield $enhancer;
+            $enhancers[] = $enhancer;
         }
+        return $enhancers;
     }
 
     /**
-     * Returns possible URL parts for a string like /home/about-us/offices/
+     * Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json
      * to return.
      *
      * /home/about-us/offices/
+     * /home/about-us/offices.json
      * /home/about-us/offices
      * /home/about-us/
      * /home/about-us
@@ -365,6 +390,14 @@ class PageRouter implements RouterInterface
         if (empty($pathParts)) {
             return ['/'];
         }
+        // Check if the last part contains a ".", then split it
+        // @todo fix me based on enhancer configuration
+        $lastPart = array_pop($pathParts);
+        if (strpos($lastPart, '.') !== false) {
+            $pathParts = array_merge($pathParts, explode('.', $lastPart));
+        } else {
+            $pathParts[] = $lastPart;
+        }
         while (!empty($pathParts)) {
             $prefix = '/' . implode('/', $pathParts);
             $candidatePathParts[] = $prefix . '/';
@@ -431,7 +464,30 @@ class PageRouter implements RouterInterface
         }
         $page = $route->getOption('_page');
         $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $page['uid']);
-        return new PageArguments($pageId, $routeArguments, [], $remainingQueryParameters);
+        $type = $this->resolveType($route, $remainingQueryParameters);
+        return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters);
+    }
+
+    /**
+     * Retrieves type from processed route and modifies remaining query parameters.
+     *
+     * @param Route $route
+     * @param array $remainingQueryParameters reference to remaining query parameters
+     * @return string
+     */
+    protected function resolveType(Route $route, array &$remainingQueryParameters): string
+    {
+        $decoratedParameters = $route->getOption('_decoratedParameters');
+        if (!isset($decoratedParameters['type'])) {
+            return '0';
+        }
+        $type = (string)$decoratedParameters['type'];
+        unset($decoratedParameters['type']);
+        $remainingQueryParameters = array_replace_recursive(
+            $remainingQueryParameters,
+            $decoratedParameters
+        );
+        return $type;
     }
 
     /**
index 6afb943..204bff5 100644 (file)
@@ -65,6 +65,25 @@ class Route extends SymfonyRoute
     }
 
     /**
+     * @param array $arguments
+     * @deprecated Probably not required
+     */
+    public function addArguments(array $arguments)
+    {
+        $mergedArguments = $this->getArguments();
+        foreach ($arguments as $key => $argument) {
+            if (isset($mergedArguments[$key])) {
+                throw new \OverflowException(
+                    sprintf('Cannot override argument %s', $key),
+                    1538326790
+                );
+            }
+            $mergedArguments[$key] = $argument;
+        }
+        $this->setOption('_arguments', $mergedArguments);
+    }
+
+    /**
      * @return EnhancerInterface|null
      */
     public function getEnhancer(): ?EnhancerInterface
@@ -107,6 +126,12 @@ class Route extends SymfonyRoute
     public function addAspects(array $aspects): self
     {
         foreach ($aspects as $key => $aspect) {
+            if (isset($this->aspects[$key])) {
+                throw new \OverflowException(
+                    sprintf('Cannot override aspect %s', $key),
+                    1538326791
+                );
+            }
             $this->aspects[$key] = $aspect;
         }
         $this->compiled = null;
index 688bcbb..881ccba 100644 (file)
@@ -118,6 +118,7 @@ return [
             'enhancers' => [
                 'Simple' => \TYPO3\CMS\Core\Routing\Enhancer\SimpleEnhancer::class,
                 'Plugin' => \TYPO3\CMS\Core\Routing\Enhancer\PluginEnhancer::class,
+                'PageType' => \TYPO3\CMS\Core\Routing\Enhancer\PageTypeDecorator::class,
                 'Extbase' => \TYPO3\CMS\Extbase\Routing\ExtbasePluginEnhancer::class,
             ],
             'aspects' => [
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-86160-PageTypeEnhancerForMappingTypeParameter.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-86160-PageTypeEnhancerForMappingTypeParameter.rst
new file mode 100644 (file)
index 0000000..7317a05
--- /dev/null
@@ -0,0 +1,68 @@
+.. include:: ../../Includes.txt
+
+==============================================================
+Feature: #86160 - PageTypeEnhancer for mapping &type parameter
+==============================================================
+
+See :issue:`86160`
+
+Description
+===========
+
+A new Route Enhancer is added to the newly introduced Routing functionality which allows to add
+a suffix to the existing route (including existing other enhancers) to map a page type (GET parameter &type=)
+to a suffix.
+
+It is now possible to map various page types to endings.
+
+Example TypoScript:
+
+:ts:
+
+       page = PAGE
+       page.typeNum = 0
+       page.10 = TEXT
+       page.10.value = Default page
+
+       rssfeed = PAGE
+       rssfeed.typeNum = 13
+       rssfeed.10 < plugin.tx_myplugin
+       rssfeed.config.disableAllHeaderCode = 1
+       rssfeed.config.additionalHeaders.10.header = Content-Type: xml/rss
+
+       jsonview = PAGE
+       jsonview.typeNum = 26
+       jsonview.10 = USER
+       jsonview.10.userFunc = MyVendor\MyExtension\Controller\JsonPageController->renderAction
+       jsonview.10.config.disableAllHeaderCode = 1
+       jsonview.10.config.additionalHeaders.10.header = Content-Type: application/json
+
+
+Now configure the Route Enhancer in your site's `config.yaml` file like this:
+
+
+:yaml:
+       routeEnhancers:
+         PageTypeSuffix:
+           type: PageType
+           default: ''
+           map:
+             'rss.feed': 13
+             '.json': 26
+
+It is also possible to set `default` to e.g. ".html" to add a ".html" suffix to all default pages.
+
+The `map` allows to add a filename or a file ending and map this to a `page.typeNum` value.
+
+
+Impact
+======
+
+The TYPO3 Frontend-internal `&type` parameter can now also be part of a speaking URL with a simple line
+of configuration.
+
+Please note that the implementation is a Decorator Enhancer, which means that the PageTypeEnhancer is only
+there for adding suffixes to an existing route / variant, but not to substitute something within the middle
+of a speaking URL segment.
+
+.. index:: Frontend
index eff8825..7e76484 100644 (file)
@@ -57,7 +57,7 @@ class PageRouterTest extends UnitTestCase
         $subject->expects($this->once())->method('getPagesFromDatabaseForCandidates')->willReturn([$pageRecord]);
         $routeResult = $subject->matchRequest($request, $previousResult);
 
-        $expectedRouteResult = new PageArguments(13, [], [], []);
+        $expectedRouteResult = new PageArguments(13, '0', [], [], []);
         $this->assertEquals($expectedRouteResult, $routeResult);
     }
 
index 53a7088..6a7ccb5 100644 (file)
@@ -91,6 +91,7 @@ class PageResolver implements MiddlewareInterface
                 // Legacy URIs (?id=12345) takes precedence, not matter if a route is given
                 $pageArguments = new PageArguments(
                     (int)($page['l10n_parent'] ?: $page['uid']),
+                    (string)($request->getQueryParams()['type'] ?? '0'),
                     [],
                     [],
                     $request->getQueryParams()
@@ -108,7 +109,7 @@ class PageResolver implements MiddlewareInterface
             }
 
             $this->controller->id = $pageArguments->getPageId();
-            $this->controller->type = $pageArguments->getArguments()['type'] ?? $this->controller->type;
+            $this->controller->type = $pageArguments->getPageType() ?? $this->controller->type;
             $request = $request->withAttribute('routing', $pageArguments);
             // stop in case arguments are dirty (=defined twice in route and GET query parameters)
             if ($pageArguments->areDirty()) {
index bcb0f6c..9fae1e2 100644 (file)
@@ -112,7 +112,7 @@ class PageResolverTest extends UnitTestCase
         $request = $request->withAttribute('site', $site);
         $request = $request->withAttribute('language', $language);
         $request = $request->withAttribute('routing', new SiteRouteResult($request->getUri(), $site, $language, 'mr-magpie/bloom'));
-        $expectedRouteResult = new PageArguments(13, []);
+        $expectedRouteResult = new PageArguments(13, '0', []);
 
         $pageRouterMock = $this->getMockBuilder(PageRouter::class)->disableOriginalConstructor()->setMethods(['matchRequest'])->getMock();
         $pageRouterMock->expects($this->once())->method('matchRequest')->willReturn($expectedRouteResult);
@@ -155,7 +155,7 @@ class PageResolverTest extends UnitTestCase
         $request = $request->withAttribute('language', $language);
         $request = $request->withAttribute('routing', new SiteRouteResult($request->getUri(), $site, $language, 'mr-magpie/bloom/'));
 
-        $expectedRouteResult = new PageArguments(13, []);
+        $expectedRouteResult = new PageArguments(13, '0', []);
         $pageRouterMock = $this->getMockBuilder(PageRouter::class)->disableOriginalConstructor()->setMethods(['matchRequest'])->getMock();
         $pageRouterMock->expects($this->once())->method('matchRequest')->willReturn($expectedRouteResult);
         $site->expects($this->any())->method('getRouter')->willReturn($pageRouterMock);
@@ -195,7 +195,7 @@ class PageResolverTest extends UnitTestCase
         $request = $request->withAttribute('language', $language);
         $request = $request->withAttribute('routing', new SiteRouteResult($request->getUri(), $site, $language, 'mr-magpie/bloom/'));
 
-        $expectedRouteResult = new PageArguments(13, []);
+        $expectedRouteResult = new PageArguments(13, '0', []);
         $pageRouterMock = $this->getMockBuilder(PageRouter::class)->disableOriginalConstructor()->setMethods(['matchRequest'])->getMock();
         $pageRouterMock->expects($this->once())->method('matchRequest')->willReturn($expectedRouteResult);
         $site->expects($this->any())->method('getRouter')->willReturn($pageRouterMock);