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 = ''; // --- 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); } } 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; } $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'] : []; $description = $this->crop($description, 300); $absImage = $image !== '' ? $this->absolute($image, $base) : ''; if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) { $meta[] = ['name' => 'description', 'content' => $description]; } $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']); } } } 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). Sort by depth and take the entry before last. $rootLine = $pageInfo->getRootLine(); ksort($rootLine); $byDepth = array_values($rootLine); $parent = count($byDepth) >= 3 ? ($byDepth[count($byDepth) - 2] ?? null) : null; if (!is_array($parent)) { return ''; } if (in_array((int)($parent['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) { return ''; } $name = trim((string)($parent['nav_title'] ?? '')) ?: trim((string)($parent['title'] ?? '')); if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) { return ''; } return $name; } /** * 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.,;:") . '...'; } }