[BUGFIX] Streamline PageTypeDecorator handling
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Routing / Enhancer / PageTypeDecorator.php
1 <?php
2 declare(strict_types = 1);
3
4 namespace TYPO3\CMS\Core\Routing\Enhancer;
5
6 /*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19 use TYPO3\CMS\Core\Routing\Route;
20 use TYPO3\CMS\Core\Routing\RouteCollection;
21
22 /**
23 * Resolves a static list (like page.typeNum) against a file pattern. Usually added on the very last part
24 * of the URL.
25 * It is important that the PageType Enhancer is executed at the very end in your configuration, as it modifies
26 * EXISTING route variants.
27 *
28 * routeEnhancers:
29 * PageTypeSuffix:
30 * type: PageType
31 * default: ''
32 * index: 'index'
33 * map:
34 * '.html': 1
35 * 'menu.json': 13
36 */
37 class PageTypeDecorator extends AbstractEnhancer implements DecoratingEnhancerInterface
38 {
39 protected const ROUTE_PATH_DELIMITERS = ['.', '-', '_'];
40
41 /**
42 * @var array
43 */
44 protected $configuration;
45
46 /**
47 * @var string
48 */
49 protected $default;
50
51 /**
52 * @var string
53 */
54 protected $index;
55
56 /**
57 * @var array
58 */
59 protected $map;
60
61 /**
62 * @param array $configuration
63 */
64 public function __construct(array $configuration)
65 {
66 $default = $configuration['default'] ?? '';
67 $index = $configuration['index'] ?? 'index';
68 $map = $configuration['map'] ?? null;
69
70 if (!is_string($default)) {
71 throw new \InvalidArgumentException('default must be string', 1538327508);
72 }
73 if (!is_string($index)) {
74 throw new \InvalidArgumentException('index must be string', 1538327509);
75 }
76 if (!is_array($map)) {
77 throw new \InvalidArgumentException('map must be array', 1538327510);
78 }
79
80 $this->configuration = $configuration;
81 $this->default = $default;
82 $this->index = $index;
83 $this->map = array_map('strval', $map);
84 }
85
86 /**
87 * @return string
88 */
89 public function getRoutePathRedecorationPattern(): string
90 {
91 return $this->buildRegularExpressionPattern(false);
92 }
93
94 /**
95 * {@inheritdoc}
96 */
97 public function decorateForMatching(RouteCollection $collection, string $routePath): void
98 {
99 $decoratedRoutePath = null;
100 $decoratedParameters = null;
101
102 $pattern = $this->buildRegularExpressionPattern();
103 if (preg_match('#(?P<decoration>(?:' . $pattern . '))#', $routePath, $matches, PREG_UNMATCHED_AS_NULL)) {
104 if (!isset($matches['decoration'])) {
105 throw new \UnexpectedValueException(
106 'Unexpected null value at end of URL',
107 1538335671
108 );
109 }
110
111 $routePathValue = $matches['decoration'];
112 $parameterValue = $matches['indexItems'] ?? $matches['slashedItems'] ?? $matches['regularItems'];
113 $routePathValuePattern = $this->quoteForRegularExpressionPattern($routePathValue) . '$';
114 $decoratedRoutePath = preg_replace('#' . $routePathValuePattern . '#', '', $routePath);
115
116 $mappedType = $this->map[$parameterValue];
117 if ($mappedType !== null) {
118 $decoratedParameters = ['type' => $mappedType];
119 } elseif ($this->default === $routePathValue) {
120 $decoratedParameters = ['type' => 0];
121 }
122 }
123
124 foreach ($collection->all() as $route) {
125 if ($decoratedRoutePath !== null) {
126 $route->setOption(
127 '_decoratedRoutePath',
128 '/' . trim($decoratedRoutePath, '/')
129 );
130 }
131 if ($decoratedParameters !== null) {
132 $route->setOption(
133 '_decoratedParameters',
134 $decoratedParameters
135 );
136 }
137 }
138 }
139
140 /**
141 * {@inheritdoc}
142 */
143 public function decorateForGeneration(RouteCollection $collection, array $parameters): void
144 {
145 $type = isset($parameters['type']) ? (string)$parameters['type'] : null;
146 $value = $this->resolveValue($type);
147
148 $considerIndex = $value !== ''
149 && in_array($value{0}, static::ROUTE_PATH_DELIMITERS);
150 if ($value !== '' && !in_array($value{0}, static::ROUTE_PATH_DELIMITERS)) {
151 $value = '/' . $value;
152 }
153
154 /**
155 * @var string $routeName
156 * @var Route $existingRoute
157 */
158 foreach ($collection->all() as $routeName => $existingRoute) {
159 $existingRoutePath = rtrim($existingRoute->getPath(), '/');
160 if ($considerIndex && $existingRoutePath === '') {
161 $existingRoutePath = $this->index;
162 }
163 $existingRoute->setPath($existingRoutePath . $value);
164 $deflatedParameters = $existingRoute->getOption('deflatedParameters') ?? $parameters;
165 if (isset($deflatedParameters['type'])) {
166 unset($deflatedParameters['type']);
167 $existingRoute->setOption(
168 'deflatedParameters',
169 $deflatedParameters
170 );
171 }
172 }
173 }
174
175 /**
176 * Checks if the value exists inside the map.
177 *
178 * @param string|null $type
179 * @return string
180 */
181 protected function resolveValue(?string $type): string
182 {
183 $index = array_search($type, $this->map, true);
184 if ($index !== false) {
185 return $index;
186 }
187 return $this->default;
188 }
189
190 /**
191 * Builds a regexp out of the map.
192 *
193 * @param bool $useNames
194 * @return string
195 */
196 protected function buildRegularExpressionPattern(bool $useNames = true): string
197 {
198 $items = array_keys($this->map);
199 if ($this->default !== '' && !in_array($this->default, $items, true)) {
200 $items[] = $this->default;
201 }
202 $slashedItems = array_filter($items, [$this, 'needsSlashPrefix']);
203 $regularItems = array_diff($items, $slashedItems);
204
205 $slashedItems = array_map([$this, 'quoteForRegularExpressionPattern'], $slashedItems);
206 $regularItems = array_map([$this, 'quoteForRegularExpressionPattern'], $regularItems);
207
208 $patterns = [];
209 if (!empty($slashedItems)) {
210 $name = $useNames ? '?P<slashedItems>' : '';
211 $patterns[] = '(?:^|/)(' . $name . implode('|', $slashedItems) . ')';
212 }
213 if (!empty($regularItems) && !empty($this->index)) {
214 $name = $useNames ? '?P<indexItems>' : '';
215 $indexPattern = $this->quoteForRegularExpressionPattern($this->index);
216 $patterns[] = '(' . $name . $indexPattern . '(?:' . implode('|', $regularItems) . '))';
217 }
218 if (!empty($regularItems)) {
219 $name = $useNames ? '?P<regularItems>' : '';
220 $patterns[] = '(' . $name . implode('|', $regularItems) . ')';
221 }
222 return '(?:' . implode('|', $patterns) . ')$';
223 }
224
225 /**
226 * Helper method for regexps.
227 *
228 * @param string $value
229 * @return string
230 */
231 protected function quoteForRegularExpressionPattern(string $value): string
232 {
233 return preg_quote($value, '#');
234 }
235
236 /**
237 * Checks if a slash should be prefixed.
238 *
239 * @param string $value
240 * @return bool
241 */
242 protected function needsSlashPrefix(string $value): bool
243 {
244 return !in_array(
245 $value{0} ?? '',
246 static::ROUTE_PATH_DELIMITERS,
247 true
248 );
249 }
250 }