• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TYPO3-Headless / headless / 25563299292

08 May 2026 03:09PM UTC coverage: 74.7% (+1.7%) from 73.04%
25563299292

push

github

lukaszuznanski
[FEATURE] TYPO3 v14 compatibility (5.0.0-rc1)

Drops support for TYPO3 12/13. Bumps required PHP/TYPO3 versions,
removes deprecated PreviewController XClass, adds LanguageMenuProcessor
and assorted v14 adaptations across data processors, middlewares,
SEO, and tests.

38 of 58 new or added lines in 10 files covered. (65.52%)

23 existing lines in 2 files now uncovered.

1181 of 1581 relevant lines covered (74.7%)

6.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.24
/Classes/Utility/UrlUtility.php
1
<?php
2

3
/*
4
 * This file is part of the "headless" Extension for TYPO3 CMS.
5
 *
6
 * For the full copyright and license information, please read the
7
 * LICENSE.md file that was distributed with this source code.
8
 */
9

10
declare(strict_types=1);
11

12
namespace FriendsOfTYPO3\Headless\Utility;
13

14
use Psr\Http\Message\ServerRequestInterface;
15
use Psr\Http\Message\UriInterface;
16
use Psr\Log\LoggerAwareInterface;
17
use Psr\Log\LoggerAwareTrait;
18
use Symfony\Component\ExpressionLanguage\SyntaxError;
19
use TYPO3\CMS\Core\Configuration\Features;
20
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
21
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
22
use TYPO3\CMS\Core\Http\Uri;
23
use TYPO3\CMS\Core\Site\Entity\Site;
24
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
25
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
26
use TYPO3\CMS\Core\Site\SiteFinder;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28

29
use function array_key_exists;
30
use function array_merge;
31
use function array_unique;
32
use function ltrim;
33
use function rtrim;
34
use function str_contains;
35
use function str_starts_with;
36
use function strlen;
37
use function substr;
38

39
class UrlUtility implements LoggerAwareInterface, HeadlessFrontendUrlInterface
40
{
41
    use LoggerAwareTrait;
42

43
    private array $conf = [];
44
    private array $variants = [];
45
    private array $frontendDomains = [];
46

47
    public function __construct(
48
        private readonly Features $features,
49
        private readonly Resolver $resolver,
50
        private readonly SiteFinder $siteFinder,
51
        private HeadlessModeInterface $headlessMode,
52
    ) {}
49✔
53

54
    public function withSite(Site $site): HeadlessFrontendUrlInterface
55
    {
56
        return $this->handleSiteConfiguration($site, clone $this);
12✔
57
    }
58

59
    public function withRequest(ServerRequestInterface $request): HeadlessFrontendUrlInterface
60
    {
61
        return $this->extractConfigurationFromRequest($request, clone $this);
7✔
62
    }
63

64
    public function withLanguage(SiteLanguage $language): HeadlessFrontendUrlInterface
65
    {
66
        return $this->handleLanguageConfiguration($language, clone $this);
1✔
67
    }
68

69
    public function getFrontendUrlWithSite($url, SiteInterface $site, string $returnField = 'frontendBase'): string
70
    {
71
        $clone = clone $this;
13✔
72
        $clone->handleSiteConfiguration($site, $clone);
13✔
73
        $siteLanguage = $clone->overrideByLanguageIfNecessary($clone, $site, $url);
13✔
74
        if ($siteLanguage !== null) {
13✔
NEW
75
            $clone->handleLanguageConfiguration($siteLanguage, $clone);
×
76
        }
77

78
        if (!$clone->headlessMode->isEnabled() || $clone->alreadyFrontendLink($url)) {
13✔
79
            return $url;
7✔
80
        }
81

82
        try {
83
            $frontendBaseUrl = $clone->resolveWithVariants(
9✔
84
                $clone->conf[$returnField] ?? '',
9✔
85
                $clone->variants,
9✔
86
                $returnField
9✔
87
            );
9✔
88

89
            if ($frontendBaseUrl === '') {
9✔
90
                return $url;
2✔
91
            }
92

93
            $frontendBase = GeneralUtility::makeInstance(Uri::class, $clone->sanitizeBaseUrl($frontendBaseUrl));
7✔
94
            $frontBase = $frontendBase->getHost();
7✔
95
            $frontExtraPath = $frontendBase->getPath();
7✔
96
            $frontPort = $frontendBase->getPort();
7✔
97
            $targetUri = new Uri($clone->sanitizeBaseUrl($url));
7✔
98
            $targetUri = $targetUri->withHost($frontBase);
7✔
99
            if ($targetUri->getScheme() === '') {
7✔
100
                $targetUri = $targetUri->withScheme($frontendBase->getScheme());
1✔
101
            }
102

103
            if ($targetUri->getFragment() !== '') {
7✔
104
                $targetUri = $targetUri->withHost('');
1✔
105
                $targetUri = $targetUri->withScheme('');
1✔
106
            }
107

108
            if ($frontExtraPath) {
7✔
109
                $targetUri = $targetUri->withPath($clone->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath()));
2✔
110
            }
111

112
            if ($site->getBase()->getPort() === $frontPort) {
7✔
113
                return (string)$targetUri;
5✔
114
            }
115

116
            if ($frontPort) {
2✔
117
                $targetUri = $targetUri->withPort($frontPort);
2✔
118
            }
119

120
            return (string)$targetUri;
2✔
121
        } catch (SiteNotFoundException $e) {
×
122
            $this->logError($e->getMessage());
×
123
        }
124

125
        return $url;
×
126
    }
127

128
    public function getFrontendUrlForPage(string $url, int $pageUid, string $returnField = 'frontendBase'): string
129
    {
130
        try {
131
            return $this->getFrontendUrlWithSite(
8✔
132
                $url,
8✔
133
                $this->siteFinder->getSiteByPageId($pageUid),
8✔
134
                $returnField
8✔
135
            );
8✔
136
        } catch (SiteNotFoundException $e) {
1✔
137
            $this->logError($e->getMessage());
1✔
138
        }
139

140
        return $url;
1✔
141
    }
142

143
    public function getFrontendUrl(): string
144
    {
145
        return $this->resolveWithVariants($this->conf['frontendBase'] ?? '', $this->variants);
8✔
146
    }
147

148
    public function getProxyUrl(): string
149
    {
150
        return $this->resolveWithVariants($this->conf['frontendApiProxy'] ?? '', $this->variants, 'frontendApiProxy');
5✔
151
    }
152

153
    public function getStorageProxyUrl(): string
154
    {
155
        return $this->resolveWithVariants($this->conf['frontendFileApi'] ?? '', $this->variants, 'frontendFileApi');
4✔
156
    }
157

158
    public function resolveKey(string $key): string
159
    {
160
        return $this->resolveWithVariants($this->conf[$key] ?? '', $this->variants, $key);
4✔
161
    }
162

163
    public function prepareRelativeUrlIfPossible(string $targetUrl): string
164
    {
165
        $parsedTargetUrl = new Uri($this->sanitizeBaseUrl($targetUrl));
5✔
166
        $parsedProjectFrontendUrl = new Uri($this->sanitizeBaseUrl($this->getFrontendUrl()));
5✔
167

168
        if ($parsedTargetUrl->getHost() === $parsedProjectFrontendUrl->getHost()) {
5✔
169
            return '/' . ltrim($parsedTargetUrl->getPath() . ($parsedTargetUrl->getQuery() ? '?' . $parsedTargetUrl->getQuery() : ''), '/');
4✔
170
        }
171

172
        return $targetUrl;
3✔
173
    }
174

175
    /**
176
     * @codeCoverageIgnore
177
     */
178
    protected function logError(string $message): void
179
    {
180
        if ($this->logger) {
181
            $this->logger->error($message);
182
        }
183
    }
184

185
    /**
186
     * If a site base contains "/" or "www.domain.com", it is ensured that
187
     * parse_url() can handle this kind of configuration properly.
188
     */
189
    private function sanitizeBaseUrl(string $base): string
190
    {
191
        if (str_starts_with($base, '#')) {
18✔
192
            return $base;
1✔
193
        }
194

195
        // no protocol ("//") and the first part is no "/" (path), means that this is a domain like
196
        // "www.domain.com/blabla", and we want to ensure that this one then gets a "no-scheme agnostic" part
197
        if (!empty($base) && !str_contains($base, '//')   && $base[0] !== '/') {
18✔
198
            // either a scheme is added, or no scheme but with domain, or a path which is not absolute
199
            // make the base prefixed with a slash, so it is recognized as path, not as domain
200
            // treat as path
201
            if (!str_contains($base, '.')) {
1✔
202
                $base = '/' . $base;
1✔
203
            } else {
204
                // treat as domain name
205
                $base = '//' . $base;
1✔
206
            }
207
        }
208
        return $base;
18✔
209
    }
210

211
    private function resolveWithVariants(
212
        string $frontendUrl,
213
        array $variants = [],
214
        string $returnField = 'frontendBase'
215
    ): string {
216
        $frontendUrl = rtrim($frontendUrl, '/');
17✔
217
        if ($variants === []) {
17✔
218
            return $frontendUrl;
5✔
219
        }
220

221
        foreach ($variants as $baseVariant) {
13✔
222
            try {
223
                if ($this->resolver->evaluate($baseVariant['condition'])) {
13✔
224
                    return rtrim($baseVariant[$returnField] ?? '', '/');
13✔
225
                }
226
            } catch (SyntaxError $e) {
1✔
227
                $this->logError($e->getMessage());
1✔
228
                // silently fail and do not evaluate
229
                // no logger here, as Site is currently cached and serialized
230
            }
231
        }
232
        return $frontendUrl;
3✔
233
    }
234

235
    private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
236
    {
237
        $langConf = $language->toArray();
3✔
238
        $variants = $langConf['baseVariants'] ?? [];
3✔
239
        $frontendBase = trim($langConf['frontendBase'] ?? '');
3✔
240
        $frontendApiProxy = trim($langConf['frontendApiProxy'] ?? '');
3✔
241
        $frontendFileApi = trim($langConf['frontendFileApi'] ?? '');
3✔
242
        $overrides = [];
3✔
243

244
        if ($frontendBase !== '') {
3✔
245
            $overrides['frontendBase'] =  $frontendBase;
1✔
246
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($frontendBase)))->getHost();
1✔
247
        }
248

249
        if ($frontendApiProxy !== '') {
3✔
250
            $overrides['frontendApiProxy'] =  $frontendApiProxy;
1✔
251
        }
252

253
        if ($frontendFileApi !== '') {
3✔
254
            $overrides['frontendFileApi'] =  $frontendFileApi;
1✔
255
        }
256

257
        $object->conf = array_merge($object->conf, $overrides);
3✔
258

259
        if ($variants !== []) {
3✔
260
            $object->variants = $variants;
2✔
261
        }
262

263
        return $object;
3✔
264
    }
265

266
    private function handleSiteConfiguration(Site $site, UrlUtility $object): self
267
    {
268
        $object->conf = $site->getConfiguration();
19✔
269
        $object->variants = $object->conf['baseVariants'] ?? [];
19✔
270
        $object->frontendDomains = [];
19✔
271

272
        $base = trim($object->conf['frontendBase'] ?? '');
19✔
273
        if ($base !== '') {
19✔
274
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
3✔
275
        }
276

277
        return $object;
19✔
278
    }
279

280
    private function extractConfigurationFromRequest(ServerRequestInterface $request, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
281
    {
282
        $site = $request->getAttribute('site');
7✔
283

284
        if ($site instanceof Site) {
7✔
285
            $object->handleSiteConfiguration($site, $object);
4✔
286
        }
287

288
        $language = $request->getAttribute('language');
7✔
289
        if ($language instanceof SiteLanguage) {
7✔
290
            $object->handleLanguageConfiguration($language, $object);
2✔
291
        }
292

293
        $object->headlessMode = $object->headlessMode->withRequest($request);
7✔
294

295
        return $object;
7✔
296
    }
297

298
    private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string
299
    {
300
        return rtrim($frontendPath, '/') . ($targetUri->getPath() !== '' ? '/' . ltrim(substr($targetUri->getPath(), strlen($baseBackendPath)), '/') : '');
2✔
301
    }
302

303
    private function overrideByLanguageIfNecessary(UrlUtility $object, SiteInterface $site, string $backendUrl): ?SiteLanguage
304
    {
305
        $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl));
13✔
306
        $matchedLanguage = null;
13✔
307
        foreach ($site->getLanguages() as $language) {
13✔
308
            $conf = $language->toArray();
3✔
309

310
            if (!array_key_exists('frontendBase', $conf)) {
3✔
311
                continue;
3✔
312
            }
313

314
            $base = trim($conf['frontendBase'] ?? '');
×
315

316
            if ($base === '') {
×
317
                continue;
×
318
            }
319

NEW
320
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
×
321

322
            if ($language->getBase()->getHost() === $backendUri->getHost()) {
×
323
                $matchedLanguage = $language;
×
324
            } elseif ($backendUri->getPath() !== '/' && str_starts_with($backendUri->getPath(), $language->getBase()->getPath())) {
×
325
                $matchedLanguage = $language;
×
326
            }
327
        }
328

329
        $object->frontendDomains = array_unique($object->frontendDomains);
13✔
330

331
        return $matchedLanguage;
13✔
332
    }
333

334
    protected function alreadyFrontendLink(string $url): bool
335
    {
336
        $targetUri = new Uri($this->sanitizeBaseUrl($url));
9✔
337

338
        return in_array($targetUri->getHost(), $this->frontendDomains, true);
9✔
339
    }
340
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc