Files
orascheandClaude Fable 5.1 a9923e19e0 Paginierte/gefilterte Listen: eigener Titel und Description (Page N, Kategorie)
Mit dem selbstreferenzierenden Canonical (07f74e3) zaehlten
/insights/news?page=2 und ?category=... als eigene Seiten mit dem
Titel von Seite 1 (Seobility: 3 doppelte Titel, 2x Duplicate Content).
VitecMetaHandler haengt jetzt ' - Page N' bzw. die Kategorie an Titel
und Description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:49:48 +01:00

484 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use Doctrine\DBAL\ParameterType;
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Completes the page JSON's `seo` block for the headless frontend.
*
* On top of the stock headless MetaHandler this adds (SEO audit 2026-11-09):
*
* 1. Record-aware metadata: news, product and success-story detail pages
* take title, meta description and og:image from the routed record -
* the shell page itself carries none of that (news details rendered
* as <title>article</title> before this).
* 2. A uniform "| VITEC" title suffix wherever the brand is missing.
* 3. Fallback meta description from the page record (description/abstract).
* 4. og:title / og:description / og:image plus the twitter:* counterparts
* whenever they are missing; twitter:card upgrades to
* summary_large_image once an image exists.
* 5. Canonical rewrite for success stories: the public URL is
* /success-stories/<slug>, not the internal /success-stories/story/<slug>
* (see SuccessStoryPathRewrite middleware).
* 6. Mirrors the final page <title> into `meta.title` (pre-existing: the
* TypoScript lib.meta renders before the record providers run).
*
* Exception-safe: any failure keeps the stock result untouched.
*/
class VitecMetaHandler extends MetaHandler
{
public function process(ServerRequestInterface $request, array $content): array
{
$content = parent::process($request, $content);
try {
$content = $this->enrich($request, $content);
} catch (\Throwable $e) {
// keep stock output on any failure
}
$title = trim((string)($content['seo']['title'] ?? ''));
if ($title !== '' && is_array($content['meta'] ?? null)) {
$content['meta']['title'] = $title;
}
return $content;
}
/**
* @param array<string,mixed> $content
* @return array<string,mixed>
*/
private function enrich(ServerRequestInterface $request, array $content): array
{
$base = $this->resolveBase($request);
$args = $this->routeArguments($request);
$pageInfo = $request->getAttribute('frontend.page.information');
$page = $pageInfo !== null ? $pageInfo->getPageRecord() : [];
$title = trim((string)($content['seo']['title'] ?? ''));
$description = '';
$image = '';
$canonicalPath = '';
// --- record context -------------------------------------------------
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($newsUid > 0) {
$news = $this->fetchRow('tx_news_domain_model_news', ['uid' => $newsUid]);
if ($news !== null) {
$recordTitle = trim((string)($news['alternative_title'] ?? ''));
if ($recordTitle === '') {
$recordTitle = trim((string)($news['title'] ?? ''));
}
if ($recordTitle !== '') {
$title = $recordTitle;
}
$description = $this->plainText((string)($news['teaser'] ?? ''));
if ($description === '') {
$description = $this->plainText(
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
);
}
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
// Same text as a success story: the story is the original.
$storyUid = (int)($news['vitec_canonical_usecase'] ?? 0);
if ($storyUid > 0) {
$story = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $storyUid]);
$storySlug = trim((string)($story['slug'] ?? ''));
if ($storySlug !== '') {
$canonicalPath = '/success-stories/' . $storySlug;
}
}
}
} elseif ($productArg !== '') {
$product = is_numeric($productArg)
? $this->fetchRow('tx_vitec_domain_model_product', ['uid' => (int)$productArg])
: $this->fetchRow('tx_vitec_domain_model_product', ['slug' => $productArg]);
if ($product !== null) {
$description = trim((string)($product['seometa'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($product['teaser'] ?? ''));
}
$productUid = (int)$product['uid'];
$image = $this->firstImage('tx_vitec_domain_model_product', 'ogimage', $productUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_product', 'productimage', $productUid);
}
}
} elseif ($usecaseUid > 0) {
$usecase = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $usecaseUid]);
if ($usecase !== null) {
$recordTitle = trim((string)($usecase['seo_title'] ?? ''));
if ($recordTitle !== '') {
$title = $recordTitle;
}
// "Betfred HQ | VITEC" is too short to say what the page is.
$storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title));
if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) {
$title = $storyCore . ' | Success Story';
}
$description = trim((string)($usecase['seo_description'] ?? ''));
if ($description === '') {
$description = trim((string)($usecase['og_description'] ?? ''));
}
if ($description === '') {
$description = $this->plainText((string)($usecase['teaser'] ?? ''));
}
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'og_image', $usecaseUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'card_image', $usecaseUid);
}
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'hero_bgimage', $usecaseUid);
}
}
}
// --- fallbacks from the page record ----------------------------------
if ($description === '') {
$description = trim((string)($page['description'] ?? ''));
if ($description === '') {
$description = trim((string)($page['abstract'] ?? ''));
}
}
if ($image === '' && !empty($page['uid'])) {
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
}
// --- short page titles get their section as context -------------------
// "Aligo | VITEC" (Seobility: 456 titles "zu kurz") becomes
// "Aligo | AV over IP & KVM | VITEC"; only for plain pages without an
// editor-set SEO title, never for record detail pages.
// An SEO title that merely repeats the page title (the import copied
// title -> seo_title) counts as "not maintained".
$seoTitle = trim((string)($page['seo_title'] ?? ''));
$pageTitle = trim((string)($page['title'] ?? ''));
// "Aligo | VITEC" as seo_title is the import pattern, not editor work.
$seoCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $seoTitle));
$seoTitleMaintained = $seoCore !== '' && mb_strtolower($seoCore) !== mb_strtolower($pageTitle);
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
&& $pageTitle !== '' && mb_strlen($pageTitle) < 30
&& !$seoTitleMaintained
) {
$parentTitle = $this->parentTitle($pageInfo);
if ($parentTitle !== '' && mb_stripos($pageTitle, $parentTitle) === false) {
$title = $pageTitle . ' | ' . $parentTitle;
}
}
// --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC';
}
if ($title !== '') {
$content['seo']['title'] = $title;
}
// --- compose meta list ------------------------------------------------
$meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : [];
// ~155 characters is what search engines show (Seobility: 527 "zu lang").
$description = $this->crop($description, 155);
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
$meta[] = ['name' => 'description', 'content' => $description];
}
// Editorial descriptions already in the list get the same length cap.
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) {
$meta[$index]['content'] = $this->crop(trim($entry['content']), 155);
}
}
$finalDescription = $this->metaContent($meta, 'name', 'description');
if ($finalDescription === '') {
$finalDescription = $description;
}
if ($title !== '' && !$this->hasMeta($meta, 'property', 'og:title')) {
$meta[] = ['property' => 'og:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'property', 'og:description')) {
$meta[] = ['property' => 'og:description', 'content' => $finalDescription];
}
if ($absImage !== '' && !$this->hasMeta($meta, 'property', 'og:image')) {
$meta[] = ['property' => 'og:image', 'content' => $absImage];
}
if ($title !== '' && !$this->hasMeta($meta, 'name', 'twitter:title')) {
$meta[] = ['name' => 'twitter:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'name', 'twitter:description')) {
$meta[] = ['name' => 'twitter:description', 'content' => $finalDescription];
}
$finalImage = $this->metaContent($meta, 'property', 'og:image');
if ($finalImage !== '' && !$this->hasMeta($meta, 'name', 'twitter:image')) {
$meta[] = ['name' => 'twitter:image', 'content' => $finalImage];
}
if ($finalImage !== '') {
$hasCard = false;
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'twitter:card') {
$meta[$index]['content'] = 'summary_large_image';
$hasCard = true;
}
}
if (!$hasCard) {
$meta[] = ['name' => 'twitter:card', 'content' => 'summary_large_image'];
}
}
$content['seo']['meta'] = $meta;
// --- success-story canonical: public short URL ------------------------
if (is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (isset($link['href']) && is_string($link['href'])) {
$content['seo']['link'][$index]['href'] =
str_replace('/success-stories/story/', '/success-stories/', $link['href']);
}
}
}
// --- news article whose canonical is a success story --------------------
if ($canonicalPath !== '') {
$href = $this->absolute($canonicalPath, $base);
$links = is_array($content['seo']['link'] ?? null) ? $content['seo']['link'] : [];
$replaced = false;
foreach ($links as $index => $link) {
if (($link['rel'] ?? '') === 'canonical') {
$links[$index]['href'] = $href;
$replaced = true;
}
}
if (!$replaced) {
$links[] = ['href' => $href, 'rel' => 'canonical'];
}
$content['seo']['link'] = $links;
}
// --- paginated / filtered list pages: self-referencing canonical --------
// /insights/news?page=2 pointed at page 1, which marks every further
// page as a duplicate and weakens the links to its articles (0100-FE).
if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) {
$query = $request->getQueryParams();
$keep = [];
foreach (['category', 'page'] as $key) {
$value = $query[$key] ?? null;
if (!is_scalar($value) || trim((string)$value) === '') {
continue;
}
if ($key === 'page' && (int)$value <= 1) {
continue;
}
$keep[$key] = trim((string)$value);
}
if ($keep !== [] && is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) {
$content['seo']['link'][$index]['href'] =
strtok($link['href'], '?') . '?' . http_build_query($keep);
}
}
}
// Self-canonical pages need their own title/description, otherwise
// they count as duplicates of page 1 ("VITEC News and Articles" x3).
if ($keep !== []) {
$suffix = [];
if (isset($keep['category'])) {
$suffix[] = ucfirst(str_replace('-', ' ', $keep['category']));
}
if (isset($keep['page'])) {
$suffix[] = 'Page ' . (int)$keep['page'];
}
$suffixText = ' – ' . implode(' – ', $suffix);
$currentTitle = trim((string)($content['seo']['title'] ?? ''));
if ($currentTitle !== '') {
$content['seo']['title'] = preg_match('/^(.*?)(\s*\|\s*VITEC)$/i', $currentTitle, $m) === 1
? $m[1] . $suffixText . $m[2]
: $currentTitle . $suffixText;
}
foreach ($content['seo']['meta'] as $index => $entry) {
if (in_array($entry['name'] ?? '', ['description', 'twitter:description'], true)
|| ($entry['property'] ?? '') === 'og:description'
) {
$content['seo']['meta'][$index]['content'] = rtrim((string)$entry['content'], '. ') . $suffixText . '.';
}
}
}
}
return $content;
}
/**
* Navigation title of the parent page, or '' when the parent is the site
* root, a technical container (user_nav, folders) or missing.
*/
private function parentTitle($pageInfo): string
{
if ($pageInfo === null) {
return '';
}
// Rootline keys are the depth (0 = root), but the array is krsort()ed
// (current page first). Walk up from the parent, skipping structural
// levels (folders, shortcuts, spacers, technical names) until a page
// with a presentable name turns up; the site root never counts.
$rootLine = $pageInfo->getRootLine();
ksort($rootLine);
$byDepth = array_values($rootLine);
for ($i = count($byDepth) - 2; $i >= 1; $i--) {
$candidate = $byDepth[$i];
if (!is_array($candidate)) {
continue;
}
if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
continue;
}
$name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? ''));
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
continue;
}
return $name;
}
return '';
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments(ServerRequestInterface $request): array
{
$routing = $request->getAttribute('routing');
$args = $routing instanceof PageArguments ? $routing->getArguments() : [];
// Query-parameter fallback (uncached plugin context).
return array_replace_recursive($request->getQueryParams(), $args);
}
/**
* @param array<string,int|string> $where equality constraints
* @return array<string,mixed>|null
*/
private function fetchRow(string $table, array $where): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$constraints = [
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
];
foreach ($where as $field => $value) {
$constraints[] = is_int($value)
? $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter((string)$value, ParameterType::STRING));
}
$row = $qb->select('*')->from($table)->where(...$constraints)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
}
/**
* Root-relative URL of the first FAL image attached to a record field.
*/
private function firstImage(string $table, string $field, int $uid): string
{
if ($uid <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
return $identifier !== '' ? '/fileadmin' . $identifier : '';
}
/**
* @param list<array<string,string>> $meta
*/
private function hasMeta(array $meta, string $attribute, string $key): bool
{
return $this->metaContent($meta, $attribute, $key) !== '';
}
/**
* @param list<array<string,string>> $meta
*/
private function metaContent(array $meta, string $attribute, string $key): string
{
foreach ($meta as $entry) {
if (strtolower((string)($entry[$attribute] ?? '')) === $key) {
return trim((string)($entry['content'] ?? ''));
}
}
return '';
}
private function resolveBase(ServerRequestInterface $request): string
{
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
return $url;
}
return $base . '/' . ltrim($url, '/');
}
/**
* Collapse RTE HTML to a single plain-text line.
*/
private function plainText(string $value): string
{
$value = strip_tags($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* Crop to $max characters at a word boundary (ellipsis appended).
*/
private function crop(string $value, int $max): string
{
if ($value === '' || mb_strlen($value) <= $max) {
return $value;
}
$cut = mb_substr($value, 0, $max);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > (int)($max * 0.6)) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " \t.,;:") . '...';
}
}