article 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/, not the internal /success-stories/story/ * (see SuccessStoryPathRewrite middleware). * 6. Mirrors the final page 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); } } } } 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.,;:") . '...'; } }