fix: render product detail pages on TYPO3 13

- ObjectManager replaced by GeneralUtility::makeInstance(), the
  services fetched that way are public
- QueryBuilder::add('orderBy') and the remaining execute() calls,
  detailAction returns a response
- PHP 8 warnings for missing array keys (same values as on PHP 7.4)
- Webcam images are external URLs: plain img tag, f:image only takes
  FAL files
- typo3_encore 7 needs the PKG:ssch/typo3-encore: prefix and
  external = 1 so TYPO3's path check does not drop the entry
- CDN image rewriting as a middleware instead of EXT:replacer, which
  is not PHP 8.5 clean; same pattern, only for page responses
- vhs 8 for the ViewHelpers the templates use
- OpenGraph meta tag manager skips the backend
- myEpUrl with trailing slash as in production

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-21 14:34:10 +02:00
1 parent 4eb1de2e29
commit 34f2d55f2c
23 files changed
+222 -57

No files matched your search

@@ -30,7 +30,6 @@ namespace EP\EpTheme\DataProcessing;
use EP\EpProducts\Service\ResellerDataService;
use EP\EpProducts\Traits\DbConnectionTrait;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
@@ -77,10 +76,8 @@ class ContextProcessor implements DataProcessorInterface
*/
private function getResellerData()
{
/** @var \TYPO3\CMS\Extbase\Object\ObjectManager $objectManager */
$objectManager = GeneralUtility::makeInstance(ObjectManager::class);
/** @var ResellerDataService $resellerService */
$resellerService = $objectManager->get(ResellerDataService::class);
$resellerService = GeneralUtility::makeInstance(ResellerDataService::class);
return $resellerService->getResellerForCurrentDomain();
}
@@ -52,7 +52,7 @@ class FlexFormProcessor implements DataProcessorInterface
$data = $cObj->data;
if (array_key_exists('pi_flexform', $data)) {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$processedData['data']['flexForm'] = $flexFormService->convertFlexFormContentToArray($data['pi_flexform']);
$processedData['data']['flexForm'] = $flexFormService->convertFlexFormContentToArray((string)$data['pi_flexform']);
}
return $processedData;
}
@@ -37,7 +37,6 @@ use EP\EpProducts\Domain\Repository\RegionRepository;
use EP\EpProducts\Service\HotelDataService;
use EP\EpProducts\Service\ProductDataService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
@@ -58,40 +57,38 @@ class TeaserProcessor implements DataProcessorInterface
array $processorConfiguration,
array $processedData
) {
/** @var \TYPO3\CMS\Extbase\Object\ObjectManager $objectManager */
$objectManager = GeneralUtility::makeInstance(ObjectManager::class);
if ((int) $processedData['data']['tx_eptheme_context_city'] > 0) {
/** @var CityRepository $repository */
$repository = $objectManager->get(CityRepository::class);
$repository = GeneralUtility::makeInstance(CityRepository::class);
/** @var City $city */
$city = $repository->findByIdentifier($processedData['data']['tx_eptheme_context_city']);
$processedData['city'] = $city;
}
if ((int) $processedData['data']['tx_eptheme_context_region'] > 0) {
/** @var RegionRepository $repository */
$repository = $objectManager->get(RegionRepository::class);
$repository = GeneralUtility::makeInstance(RegionRepository::class);
/** @var Region $region */
$region = $repository->findByIdentifier($processedData['data']['tx_eptheme_context_region']);
$processedData['region'] = $region;
}
if ((int) $processedData['data']['tx_eptheme_context_country'] > 0) {
/** @var CountryRepository $repository */
$repository = $objectManager->get(CountryRepository::class);
$repository = GeneralUtility::makeInstance(CountryRepository::class);
/** @var Country $country */
$country = $repository->findByIdentifier($processedData['data']['tx_eptheme_context_country']);
$processedData['country'] = $country;
}
if ((int) $processedData['data']['tx_eptheme_context_hotel'] > 0) {
/** @var HotelDataService $service */
$service = $objectManager->get(HotelDataService::class);
$service = GeneralUtility::makeInstance(HotelDataService::class);
/** @var array $hotel */
$hotel = $service->getTeaser($processedData['data']['tx_eptheme_context_hotel']);
$processedData['hotel'] = $hotel;
}
if ((int) $processedData['data']['tx_eptheme_context_concept'] > 0) {
/** @var ConceptRepository $repository */
$repository = $objectManager->get(ConceptRepository::class);
$repository = GeneralUtility::makeInstance(ConceptRepository::class);
/** @var Concept $concept */
$concept = $repository->findByIdentifier($processedData['data']['tx_eptheme_context_concept']);
$processedData['concept'] = $concept;
@@ -119,7 +116,7 @@ class TeaserProcessor implements DataProcessorInterface
}
/** @var ProductDataService $productDataService */
$productDataService = $objectManager->get(ProductDataService::class);
$productDataService = GeneralUtility::makeInstance(ProductDataService::class);
$processedData['teaser'] = $productDataService->getTeaser($filterSettings);
$processedData['teaser']['forceHotelUid'] = $hotelUid;
}
@@ -55,7 +55,12 @@ class OpenGraphMetaTagManager extends \TYPO3\CMS\Seo\MetaTag\OpenGraphMetaTagMan
*/
protected function getPageWithImage()
{
$rootLineUtility = new RootlineUtility($GLOBALS['TYPO3_REQUEST']->getAttribute('frontend.page.information')->getId());
// The manager is also asked to render in the backend, where there is no frontend page
$pageInformation = $GLOBALS['TYPO3_REQUEST']?->getAttribute('frontend.page.information');
if ($pageInformation === null) {
return null;
}
$rootLineUtility = new RootlineUtility($pageInformation->getId());
$rootLine = $rootLineUtility->get();
foreach ($rootLine as $page) {
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace EP\EpTheme\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\Stream;
/**
* Points image URLs in frontend pages at the CDN (formerly EXT:replacer).
*
* Only images go to the CDN. The theme's own css/js/fonts are served by the production
* host: they are already minified by Webpack, gzipped by mod_deflate and content-hashed
* for a year of immutable browser caching, and keeping them on the page's own origin
* means they arrive over the connection the browser has already opened rather than
* costing a second DNS lookup and TLS handshake on the critical path.
*
* It also keeps them clear of the pull zone's habit of occasionally wedging a single
* cache key (10.0s -> 504 errorcode 106 until that key is purged, while the origin
* serves the same bytes in under 100ms). For an image that degrades one element; for
* js/main.<hash>.js it would take every visitor's JavaScript down.
*
* typo3temp is scoped to generated images on purpose - assets/compressed holds other
* extensions' concatenated css/js, which belongs on the origin for the same reason.
* The quote character is captured rather than matched literally, so url() values
* written with single quotes are rewritten too.
*
* Registered after the page resolver, so the /api/* responses of ep_products never pass here.
*/
final class CdnImageMiddleware implements MiddlewareInterface
{
private const PATTERN = '/(["\'])\/?(fileadmin|uploads|typo3temp\/assets\/(?:_processed_|images))/';
public function __construct(private readonly ExtensionConfiguration $extensionConfiguration)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$cdnBaseUrl = rtrim((string)($this->extensionConfiguration->get('ep_theme', 'cdnBaseUrl') ?? ''), '/');
$contentType = $response->getHeaderLine('Content-Type');
if ($cdnBaseUrl === ''
|| !(str_starts_with($contentType, 'text/html') || str_starts_with($contentType, 'application/json'))
) {
return $response;
}
$body = (string)$response->getBody();
$replaced = preg_replace(self::PATTERN, '$1' . $cdnBaseUrl . '/$2', $body);
if ($replaced === null || $replaced === $body) {
return $response;
}
$stream = new Stream('php://temp', 'rw');
$stream->write($replaced);
return $response->withBody($stream);
}
}
@@ -0,0 +1,12 @@
<?php
return [
'frontend' => [
'ep/cdn-images' => [
'target' => \EP\EpTheme\Middleware\CdnImageMiddleware::class,
'after' => [
'typo3/cms-frontend/page-resolver',
],
],
],
];
@@ -73,12 +73,16 @@ page {
# RequestHandler turns this into `compress => false`, which PageRendererHooks forwards to
# typo3_encore's TagRenderer (whose own default it would otherwise override).
includeCSS {
main = typo3_encore:EpThemeConfig:main
main = PKG:ssch/typo3-encore:EpThemeConfig:main
# Skips TYPO3's file path check, which would drop the entry name before typo3_encore's hook sees it
main.external = 1
main.disableCompression = 1
}
includeJSFooter {
main = typo3_encore:EpThemeConfig:main
main = PKG:ssch/typo3-encore:EpThemeConfig:main
# Skips TYPO3's file path check, which would drop the entry name before typo3_encore's hook sees it
main.external = 1
main.defer = 1
main.disableCompression = 1
}
@@ -12,11 +12,8 @@
data-action="lightbox#open"
data-lightbox-caption="Webcam {region.name}"
title="Webcam {region.name}">
<f:image src="{region.webcam.url}"
loading="lazy"
class="block w-full h-auto"
width="330"
alt="Webcam {region.name}" />
<f:comment>External image: f:image only resolves FAL files since TYPO3 12</f:comment>
<img class="block w-full h-auto" alt="Webcam {region.name}" loading="lazy" src="{region.webcam.url}" width="330" />
</a>
</div>
</f:if>
+2
View File
@@ -0,0 +1,2 @@
# cat=CDN; type=string; label=CDN base URL for images in frontend pages (fileadmin, uploads, generated typo3temp images); empty serves them from the site itself
cdnBaseUrl = https://cdn.ep-reisen.de/