diff --git a/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php b/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php index 5d547800..ba2a0233 100644 --- a/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php +++ b/Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php @@ -12,16 +12,12 @@ namespace FriendsOfTYPO3\Headless\Event\Listener; use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; -use Psr\EventDispatcher\EventDispatcherInterface; -use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry; -use TYPO3\CMS\Core\TypoScript\TypoScriptService; +use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; -use TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent; -use function array_merge; use function json_decode; use const JSON_THROW_ON_ERROR; @@ -30,8 +26,8 @@ class AfterCacheableContentIsGeneratedListener { public function __construct( private readonly JsonEncoder $encoder, - private readonly MetaTagManagerRegistry $metaTagRegistry, - private readonly EventDispatcherInterface $eventDispatcher, + private readonly MetaHandler $metaHandler, + private readonly HeadlessUserInt $headlessUserInt, ) {} public function __invoke(AfterCacheableContentIsGeneratedEvent $event) @@ -41,110 +37,22 @@ public function __invoke(AfterCacheableContentIsGeneratedEvent $event) return; } - $content = json_decode($event->getController()->content, true, 512, JSON_THROW_ON_ERROR); - - if (($content['seo']['title'] ?? null) === null) { + if ($this->headlessUserInt->hasNonCacheableContent($event->getController()->content)) { + // we have dynamic content on page, we fire MetaHandler later on middleware return; } - $_params = ['page' => $event->getController()->page, 'request' => $event->getRequest(), '_seoLinks' => []]; - $_ref = null; - foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Frontend\Page\PageGenerator']['generateMetaTags'] ?? [] as $_funcRef) { - GeneralUtility::callUserFunction($_funcRef, $_params, $_ref); - } - - $content['seo']['title'] = $event->getController()->generatePageTitle(); - - $this->generateMetaTagsFromTyposcript($event->getController()->pSetup['meta.'] ?? [], $event->getController()->cObj); - - $metaTags = []; - $metaTagManagers = GeneralUtility::makeInstance(MetaTagManagerRegistry::class)->getAllManagers(); - - foreach ($metaTagManagers as $manager => $managerObject) { - $properties = json_decode($managerObject->renderAllProperties(), true); - if (!empty($properties)) { - $metaTags = array_merge($metaTags, $properties); - } - } - - $content['seo']['meta'] = $metaTags; - - $hrefLangs = $this->eventDispatcher->dispatch(new ModifyHrefLangTagsEvent($event->getRequest()))->getHrefLangs(); - - $seoLinks = $_params['_seoLinks'] ?? []; + $content = json_decode($event->getController()->content, true, 512, JSON_THROW_ON_ERROR); - if (count($hrefLangs) > 1) { - foreach ($hrefLangs as $hrefLang => $href) { - $seoLinks[] = ['rel' => 'alternate', 'hreflang' => $hrefLang, 'href' => $href]; - } + if (($content['seo']['title'] ?? null) === null) { + return; } - if ($seoLinks !== []) { - $content['seo']['link'] = $seoLinks; - } + $content = $this->metaHandler->process($event->getRequest(), $event->getController(), $content); $event->getController()->content = $this->encoder->encode($content); } catch (\Throwable $e) { return; } } - - /** - * @codeCoverageIgnore - */ - protected function generateMetaTagsFromTyposcript(array $metaTagTypoScript, ContentObjectRenderer $cObj) - { - $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); - $conf = $typoScriptService->convertTypoScriptArrayToPlainArray($metaTagTypoScript); - foreach ($conf as $key => $properties) { - $replace = false; - if (is_array($properties)) { - $nodeValue = $properties['_typoScriptNodeValue'] ?? ''; - $value = trim((string)$cObj->stdWrap($nodeValue, $metaTagTypoScript[$key . '.'])); - if ($value === '' && !empty($properties['value'])) { - $value = $properties['value']; - $replace = false; - } - } else { - $value = $properties; - } - - $attribute = 'name'; - if ((is_array($properties) && !empty($properties['httpEquivalent'])) || strtolower($key) === 'refresh') { - $attribute = 'http-equiv'; - } - if (is_array($properties) && !empty($properties['attribute'])) { - $attribute = $properties['attribute']; - } - if (is_array($properties) && !empty($properties['replace'])) { - $replace = true; - } - - if (!is_array($value)) { - $value = (array)$value; - } - foreach ($value as $subValue) { - if (trim($subValue ?? '') !== '') { - $this->setMetaTag($attribute, $key, $subValue, [], $replace); - } - } - } - } - - /** - * @codeCoverageIgnore - */ - private function setMetaTag(string $type, string $name, string $content, array $subProperties = [], $replace = true): void - { - $type = strtolower($type); - $name = strtolower($name); - if (!in_array($type, ['property', 'name', 'http-equiv'], true)) { - throw new \InvalidArgumentException( - 'When setting a meta tag the only types allowed are property, name or http-equiv. "' . $type . '" given.', - 1496402460 - ); - } - $manager = $this->metaTagRegistry->getManagerForProperty($name); - $manager->addProperty($name, $content, $subProperties, $replace, $type); - } } diff --git a/Classes/Middleware/UserIntMiddleware.php b/Classes/Middleware/UserIntMiddleware.php index 638da61d..8b17de65 100644 --- a/Classes/Middleware/UserIntMiddleware.php +++ b/Classes/Middleware/UserIntMiddleware.php @@ -11,6 +11,7 @@ namespace FriendsOfTYPO3\Headless\Middleware; +use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use Psr\Http\Message\ResponseInterface; @@ -18,20 +19,16 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use TYPO3\CMS\Core\Http\Stream; -use TYPO3\CMS\Core\Utility\GeneralUtility; + +use function json_decode; class UserIntMiddleware implements MiddlewareInterface { - private HeadlessUserInt $headlessUserInt; - private HeadlessMode $headlessMode; - public function __construct( - HeadlessUserInt $headlessUserInt = null, - HeadlessMode $headlessMode - ) { - $this->headlessUserInt = $headlessUserInt ?? GeneralUtility::makeInstance(HeadlessUserInt::class); - $this->headlessMode = $headlessMode; - } + private readonly HeadlessUserInt $headlessUserInt, + private readonly HeadlessMode $headlessMode, + private readonly MetaHandler $metaHandler + ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { @@ -41,11 +38,26 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - $body = $this->headlessUserInt->unwrap($response->getBody()->__toString()); + $jsonContent = $response->getBody()->__toString(); - $stream = new Stream('php://temp', 'r+'); - $stream->write($body); + if (!$this->headlessUserInt->hasNonCacheableContent($jsonContent)) { + return $response; + } + + $jsonContent = $this->headlessUserInt->unwrap($jsonContent); + $responseBody = json_decode($jsonContent, true); + if (($responseBody['seo']['title'] ?? null) !== null) { + $responseBody = $this->metaHandler->process( + $request, + $request->getAttribute('frontend.controller'), + $responseBody + ); + $jsonContent = json_encode($responseBody); + } + + $stream = new Stream('php://temp', 'r+'); + $stream->write($jsonContent); return $response->withBody($stream); } } diff --git a/Classes/Seo/MetaHandler.php b/Classes/Seo/MetaHandler.php new file mode 100644 index 00000000..7be45107 --- /dev/null +++ b/Classes/Seo/MetaHandler.php @@ -0,0 +1,132 @@ + $controller->page, 'request' => $request, '_seoLinks' => []]; + $_ref = null; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Frontend\Page\PageGenerator']['generateMetaTags'] ?? [] as $_funcRef) { + GeneralUtility::callUserFunction($_funcRef, $_params, $_ref); + } + + $content['seo']['title'] = $controller->generatePageTitle(); + + $this->generateMetaTagsFromTyposcript( + $controller->pSetup['meta.'] ?? [], + $controller->cObj + ); + + $metaTags = []; + $metaTagManagers = GeneralUtility::makeInstance(MetaTagManagerRegistry::class)->getAllManagers(); + + foreach ($metaTagManagers as $manager => $managerObject) { + $properties = json_decode($managerObject->renderAllProperties(), true); + if (!empty($properties)) { + $metaTags = array_merge($metaTags, $properties); + } + } + + $content['seo']['meta'] = $metaTags; + + $hrefLangs = $this->eventDispatcher->dispatch(new ModifyHrefLangTagsEvent($request))->getHrefLangs(); + + $seoLinks = $_params['_seoLinks'] ?? []; + + if (count($hrefLangs) > 1) { + foreach ($hrefLangs as $hrefLang => $href) { + $seoLinks[] = ['rel' => 'alternate', 'hreflang' => $hrefLang, 'href' => $href]; + } + } + + if ($seoLinks !== []) { + $content['seo']['link'] = $seoLinks; + } + + return $content; + } + + /** + * @codeCoverageIgnore + */ + protected function generateMetaTagsFromTyposcript(array $metaTagTypoScript, ContentObjectRenderer $cObj) + { + $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); + $conf = $typoScriptService->convertTypoScriptArrayToPlainArray($metaTagTypoScript); + foreach ($conf as $key => $properties) { + $replace = false; + if (is_array($properties)) { + $nodeValue = $properties['_typoScriptNodeValue'] ?? ''; + $value = trim((string)$cObj->stdWrap($nodeValue, $metaTagTypoScript[$key . '.'])); + if ($value === '' && !empty($properties['value'])) { + $value = $properties['value']; + $replace = false; + } + } else { + $value = $properties; + } + + $attribute = 'name'; + if ((is_array($properties) && !empty($properties['httpEquivalent'])) || strtolower($key) === 'refresh') { + $attribute = 'http-equiv'; + } + if (is_array($properties) && !empty($properties['attribute'])) { + $attribute = $properties['attribute']; + } + if (is_array($properties) && !empty($properties['replace'])) { + $replace = true; + } + + if (!is_array($value)) { + $value = (array)$value; + } + foreach ($value as $subValue) { + if (trim($subValue ?? '') !== '') { + $this->setMetaTag($attribute, $key, $subValue, [], $replace); + } + } + } + } + + /** + * @codeCoverageIgnore + */ + private function setMetaTag(string $type, string $name, string $content, array $subProperties = [], $replace = true): void + { + $type = strtolower($type); + $name = strtolower($name); + if (!in_array($type, ['property', 'name', 'http-equiv'], true)) { + throw new \InvalidArgumentException( + 'When setting a meta tag the only types allowed are property, name or http-equiv. "' . $type . '" given.', + 1496402460 + ); + } + $manager = $this->metaTagRegistry->getManagerForProperty($name); + $manager->addProperty($name, $content, $subProperties, $replace, $type); + } +} diff --git a/Classes/Utility/HeadlessUserInt.php b/Classes/Utility/HeadlessUserInt.php index 696e99e9..fdab7372 100644 --- a/Classes/Utility/HeadlessUserInt.php +++ b/Classes/Utility/HeadlessUserInt.php @@ -37,6 +37,11 @@ public function wrap(string $content, string $type = self::STANDARD): string ); } + public function hasNonCacheableContent(string $content): bool + { + return str_contains($content, self::STANDARD); + } + public function unwrap(string $content): string { if (str_contains($content, self::NESTED)) { diff --git a/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php b/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php index fe009f08..55cb0f80 100644 --- a/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php +++ b/Tests/Unit/Event/Listener/AfterCacheableContentIsGeneratedListenerTest.php @@ -13,9 +13,11 @@ use FriendsOfTYPO3\Headless\Event\Listener\AfterCacheableContentIsGeneratedListener; use FriendsOfTYPO3\Headless\Json\JsonEncoder; +use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Seo\MetaTag\Html5MetaTagManager; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; +use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\EventDispatcher\EventDispatcherInterface; @@ -40,7 +42,9 @@ class AfterCacheableContentIsGeneratedListenerTest extends UnitTestCase public function testNotModifiedWithInvalidOrDisabledJsonContent(): void { - $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $this->prophesize(MetaTagManagerRegistry::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal()); + $metaHandler = new MetaHandler($this->prophesize(MetaTagManagerRegistry::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal()); + + $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $metaHandler, new HeadlessUserInt()); $request = $this->prophesize(ServerRequestInterface::class); $request->getAttribute(Argument::is('headless'))->willReturn(new Headless(HeadlessMode::NONE)); @@ -69,7 +73,9 @@ public function testNotModifiedWithInvalidOrDisabledJsonContent(): void public function testNotModifiedWhileValidJson(): void { - $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $this->prophesize(MetaTagManagerRegistry::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal()); + $metaHandler = new MetaHandler($this->prophesize(MetaTagManagerRegistry::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal()); + + $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $metaHandler, new HeadlessUserInt()); $content = json_encode(['someCustomPageWithoutMeta' => ['title' => 'test before event']]); @@ -87,14 +93,38 @@ public function testNotModifiedWhileValidJson(): void self::assertSame($content, $event->getController()->content); } + public function testNotModifiedWhenUserIntContent(): void + { + $metaHandler = new MetaHandler($this->prophesize(MetaTagManagerRegistry::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal()); + + $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $metaHandler, new HeadlessUserInt()); + + $content = json_encode(['someCustomPageWithoutMeta' => ['title' => HeadlessUserInt::NESTED . '_START<<>>' . HeadlessUserInt::NESTED . '_END']]); + + $request = $this->prophesize(ServerRequestInterface::class); + $request->getAttribute(Argument::is('headless'))->willReturn(new Headless(HeadlessMode::FULL)); + + $controller = $this->prophesize(TypoScriptFrontendController::class); + $controller->content = $content; + $controller->generatePageTitle()->willReturn('Modified title via PageTitleManager'); + + $event = new AfterCacheableContentIsGeneratedEvent($request->reveal(), $controller->reveal(), 'abc', false); + + $listener($event); + + self::assertSame($content, $event->getController()->content); + } + public function testModifiedPageTitle(): void { $listenerProvider = $this->prophesize(ListenerProvider::class); $listenerProvider->getListenersForEvent(Argument::any())->willReturn([]); - $eventDispatcher = GeneralUtility::makeInstance(\TYPO3\CMS\Core\EventDispatcher\EventDispatcher::class, $listenerProvider->reveal()); + $eventDispatcher = GeneralUtility::makeInstance(EventDispatcher::class, $listenerProvider->reveal()); - $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $this->prophesize(MetaTagManagerRegistry::class)->reveal(), $eventDispatcher); + $metaHandler = new MetaHandler($this->prophesize(MetaTagManagerRegistry::class)->reveal(), $eventDispatcher); + + $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $metaHandler, new HeadlessUserInt()); $request = $this->prophesize(ServerRequestInterface::class); $request->getAttribute(Argument::is('headless'))->willReturn(new Headless(HeadlessMode::FULL)); @@ -122,7 +152,9 @@ public function testHreflangs(): void $eventDispatcher = $this->prophesize(EventDispatcher::class); $eventDispatcher->dispatch(Argument::any())->willReturn($event); - $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $this->prophesize(MetaTagManagerRegistry::class)->reveal(), $eventDispatcher->reveal()); + $metaHandler = new MetaHandler($this->prophesize(MetaTagManagerRegistry::class)->reveal(), $eventDispatcher->reveal()); + + $listener = new AfterCacheableContentIsGeneratedListener(new JsonEncoder(), $metaHandler, new HeadlessUserInt()); $request = $this->prophesize(ServerRequestInterface::class); $request->getAttribute(Argument::is('headless'))->willReturn(new Headless(HeadlessMode::FULL)); diff --git a/Tests/Unit/Middleware/UserIntMiddlewareTest.php b/Tests/Unit/Middleware/UserIntMiddlewareTest.php index b1d4d20a..1f679d54 100644 --- a/Tests/Unit/Middleware/UserIntMiddlewareTest.php +++ b/Tests/Unit/Middleware/UserIntMiddlewareTest.php @@ -12,15 +12,19 @@ namespace FriendsOfTYPO3\Headless\Tests\Unit\Middleware; use FriendsOfTYPO3\Headless\Middleware\UserIntMiddleware; +use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Utility\Headless; use FriendsOfTYPO3\Headless\Utility\HeadlessMode; use FriendsOfTYPO3\Headless\Utility\HeadlessUserInt; use Prophecy\PhpUnit\ProphecyTrait; use TYPO3\CMS\Core\Http\HtmlResponse; use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; use TYPO3\CMS\Frontend\Http\RequestHandler; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; +use function json_encode; + class UserIntMiddlewareTest extends UnitTestCase { use ProphecyTrait; @@ -30,12 +34,24 @@ class UserIntMiddlewareTest extends UnitTestCase */ public function processTest() { - $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode()); + $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $this->createMock(MetaHandler::class)); $request = new ServerRequest(); $request = $request->withAttribute('headless', new Headless(HeadlessMode::FULL)); + $intScript = ''; + $responseString = ''; + $response = new HtmlResponse($responseString); + + self::assertEquals( + $intScript, + $middleware->process( + $request, + $this->getMockHandlerWithResponse($response) + )->getBody()->__toString() + ); + $intScript = ''; $responseString = HeadlessUserInt::NESTED . '_START<<' . $intScript . '>>' . HeadlessUserInt::NESTED . '_END'; $response = new HtmlResponse($responseString); @@ -48,7 +64,7 @@ public function processTest() )->getBody()->__toString() ); - $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode()); + $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $this->createMock(MetaHandler::class)); $request = new ServerRequest(); $request = $request->withAttribute('headless', new Headless()); @@ -71,6 +87,28 @@ public function processTest() $this->getMockHandlerWithResponse($response) )->getBody()->__toString() ); + + $request = new ServerRequest(); + + $request = $request->withAttribute('headless', new Headless(HeadlessMode::FULL)); + $request = $request->withAttribute('frontend.controller', $this->createMock(TypoScriptFrontendController::class)); + + $metaHandlerMock = $this->createMock(MetaHandler::class); + $metaHandlerMock->method('process')->withAnyParameters()->willReturn(['seo' => ['title' => 'test2']]); + + $middleware = new UserIntMiddleware(new HeadlessUserInt(), new HeadlessMode(), $metaHandlerMock); + $c = json_encode(['seo' => ['title' => 'test']]); + + $responseString = '"' . HeadlessUserInt::STANDARD . '_START<<' . $c . '>>' . HeadlessUserInt::STANDARD . '_END"'; + $response = new HtmlResponse($responseString); + + self::assertEquals( + json_encode(['seo' => ['title' => 'test2']]), + $middleware->process( + $request, + $this->getMockHandlerWithResponse($response) + )->getBody()->__toString() + ); } protected function getMockHandlerWithResponse($response)