Skip to content

Commit

Permalink
[FEATURE] Introduce support of SEO related APIs from TYPO3 Core (#752)
Browse files Browse the repository at this point in the history
Patch introduces proper support of SEO related settings & APIs in TYPO3 core:

Patch adds ability to use:
- Canonical API
- MetaTag API
- Page title API

Respects `page.meta` TypoScript definitions.
  • Loading branch information
twoldanski committed Jul 18, 2024
1 parent 2eadaa7 commit d8da92c
Show file tree
Hide file tree
Showing 13 changed files with 724 additions and 19 deletions.
117 changes: 113 additions & 4 deletions Classes/Event/Listener/AfterCacheableContentIsGeneratedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,139 @@
namespace FriendsOfTYPO3\Headless\Event\Listener;

use FriendsOfTYPO3\Headless\Json\JsonEncoder;
use FriendsOfTYPO3\Headless\Utility\HeadlessMode;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
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;

class AfterCacheableContentIsGeneratedListener
{
public function __construct(private readonly JsonEncoder $encoder) {}
public function __construct(
private readonly JsonEncoder $encoder,
private readonly MetaTagManagerRegistry $metaTagRegistry,
private readonly EventDispatcherInterface $eventDispatcher,
) {}

public function __invoke(AfterCacheableContentIsGeneratedEvent $event)
{
try {
if (!GeneralUtility::makeInstance(HeadlessMode::class)->withRequest($event->getRequest())->isEnabled()) {
return;
}

$content = json_decode($event->getController()->content, true, 512, JSON_THROW_ON_ERROR);

if (($content['meta']['title'] ?? null) === null) {
if (($content['seo']['title'] ?? null) === null) {
return;
}

$content['meta']['title'] = $event->getController()->generatePageTitle();
$_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'] ?? [];

if (count($hrefLangs) > 1) {
foreach ($hrefLangs as $hrefLang => $href) {
$seoLinks[] = ['rel' => 'alternate', 'hreflang' => $hrefLang, 'href' => $href];
}
}

if ($seoLinks !== []) {
$content['seo']['link'] = $seoLinks;
}

$event->getController()->content = $this->encoder->encode($content);
} catch (\Throwable) {
} 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);
}
}
84 changes: 84 additions & 0 deletions Classes/Seo/CanonicalGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

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

declare(strict_types=1);

namespace FriendsOfTYPO3\Headless\Seo;

use FriendsOfTYPO3\Headless\Utility\HeadlessMode;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Domain\Page;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
use TYPO3\CMS\Seo\Event\ModifyUrlForCanonicalTagEvent;

use function htmlspecialchars;
use function json_encode;

/**
* Overridden core version with headless implementation
*
* @codeCoverageIgnore
*/
class CanonicalGenerator extends \TYPO3\CMS\Seo\Canonical\CanonicalGenerator
{
protected TypoScriptFrontendController $typoScriptFrontendController;
protected PageRepository $pageRepository;
protected EventDispatcherInterface $eventDispatcher;

public function handle(array &$params): string
{
if ($this->typoScriptFrontendController->config['config']['disableCanonical'] ?? false) {
return '';
}

$event = new ModifyUrlForCanonicalTagEvent('', $params['request'], new Page($params['page']));
$event = $this->eventDispatcher->dispatch($event);
$href = $event->getUrl();

if (empty($href) && (int)$this->typoScriptFrontendController->page['no_index'] === 1) {
return '';
}

if (empty($href)) {
// 1) Check if page has canonical URL set
$href = $this->checkForCanonicalLink();
}
if (empty($href)) {
// 2) Check if page show content from other page
$href = $this->checkContentFromPid();
}
if (empty($href)) {
// 3) Fallback, create canonical URL
$href = $this->checkDefaultCanonical();
}

if (!empty($href)) {
if (GeneralUtility::makeInstance(HeadlessMode::class)->withRequest($params['request'])->isEnabled()) {
$canonical = [
'href' => htmlspecialchars($href),
'rel' => 'canonical',
];

$params['_seoLinks'][] = $canonical;
$canonical = json_encode($canonical);
} else {
$canonical = '<link ' . GeneralUtility::implodeAttributes([
'rel' => 'canonical',
'href' => $href,
], true) . '/>' . LF;
$this->typoScriptFrontendController->additionalHeaderData[] = $canonical;
}

return $canonical;
}
return '';
}
}
102 changes: 102 additions & 0 deletions Classes/Seo/MetaTag/AbstractMetaTagManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

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

declare(strict_types=1);

namespace FriendsOfTYPO3\Headless\Seo\MetaTag;

use FriendsOfTYPO3\Headless\Utility\HeadlessMode;
use TYPO3\CMS\Core\Utility\GeneralUtility;

use function array_merge;
use function json_decode;
use function json_encode;

/**
* Overridden core version with headless implementation
*/
abstract class AbstractMetaTagManager extends \TYPO3\CMS\Core\MetaTag\AbstractMetaTagManager
{
public function renderAllProperties(): string
{
if (GeneralUtility::makeInstance(HeadlessMode::class)->withRequest($GLOBALS['TYPO3_REQUEST'])->isEnabled()) {
return $this->renderAllHeadlessProperties();
}

return parent::renderAllProperties();
}

public function renderProperty(string $property): string
{
if (GeneralUtility::makeInstance(HeadlessMode::class)->withRequest($GLOBALS['TYPO3_REQUEST'])->isEnabled()) {
return $this->renderHeadlessProperty($property);
}

return parent::renderProperty($property);
}

/**
* Render a meta tag for a specific property
*
* @param string $property Name of the property
*/
public function renderHeadlessProperty(string $property): string
{
$property = strtolower($property);
$metaTags = [];

$nameAttribute = $this->defaultNameAttribute;
if (isset($this->handledProperties[$property]['nameAttribute'])
&& !empty((string)$this->handledProperties[$property]['nameAttribute'])) {
$nameAttribute = (string)$this->handledProperties[$property]['nameAttribute'];
}

$contentAttribute = $this->defaultContentAttribute;
if (isset($this->handledProperties[$property]['contentAttribute'])
&& !empty((string)$this->handledProperties[$property]['contentAttribute'])) {
$contentAttribute = (string)$this->handledProperties[$property]['contentAttribute'];
}

if ($nameAttribute && $contentAttribute) {
foreach ($this->getProperty($property) as $propertyItem) {
$metaTags[] = [
htmlspecialchars($nameAttribute) => htmlspecialchars($property),
htmlspecialchars($contentAttribute) => htmlspecialchars($propertyItem['content']),
];

if (!count($propertyItem['subProperties'])) {
continue;
}
foreach ($propertyItem['subProperties'] as $subProperty => $subPropertyItems) {
foreach ($subPropertyItems as $subPropertyItem) {
$metaTags[] = [
htmlspecialchars($nameAttribute) => htmlspecialchars($property . $this->subPropertySeparator . $subProperty),
htmlspecialchars($contentAttribute) => htmlspecialchars((string)$subPropertyItem),
];
}
}
}
}

return json_encode($metaTags);
}

/**
* Render all registered properties of this manager
*/
public function renderAllHeadlessProperties(): string
{
$metatags = [];
foreach (array_keys($this->properties) as $property) {
$metatags = array_merge($metatags, json_decode($this->renderHeadlessProperty($property), true));
}

return json_encode($metatags);
}
}
25 changes: 25 additions & 0 deletions Classes/Seo/MetaTag/EdgeMetaTagManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

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

declare(strict_types=1);

namespace FriendsOfTYPO3\Headless\Seo\MetaTag;

/**
* Overridden core version with headless implementation
*/
class EdgeMetaTagManager extends AbstractMetaTagManager
{
/**
* @var string[][]
*/
protected $handledProperties = [
'x-ua-compatible' => ['nameAttribute' => 'http-equiv'],
];
}
Loading

0 comments on commit d8da92c

Please sign in to comment.