diff --git a/config/sites/vitec/config.yaml b/config/sites/vitec/config.yaml index df208b9..2e69e89 100644 --- a/config/sites/vitec/config.yaml +++ b/config/sites/vitec/config.yaml @@ -41,6 +41,23 @@ languages: solr_core_read: core_en rootPageId: 1 routeEnhancers: + # Pretty, resolvable URLs for ALL XML-sitemap parts. The typo3/seo-sitemap + # set maps only "pages"; links to the other parts were generated as + # ?sitemap= query URLs that the renderer cannot resolve (it reads + # tx_seo[sitemap]) - the index then returned itself. SEO audit 2026-11-09. + Sitemap: + type: Simple + routePath: 'sitemap-type/{sitemap}' + aspects: + sitemap: + type: StaticValueMapper + map: + pages: pages + news: news + products: products + stories: stories + _arguments: + sitemap: 'tx_seo/sitemap' ProductPlugin: type: Extbase limitToPages: diff --git a/config/sites/vitec/settings.yaml b/config/sites/vitec/settings.yaml index c2e00ff..b17c035 100644 --- a/config/sites/vitec/settings.yaml +++ b/config/sites/vitec/settings.yaml @@ -4,25 +4,22 @@ menu.meta.pageUids: '5' megamenu.contentUid: 10366 vitec.storyDetailPid: 26 felogin.pid: '520' -# felogin v14 und EXT:headless belegen beide templateRootPaths.10 - je nach -# Set-Reihenfolge gewinnt das HTML-Template. Das Setting speist genau diesen -# Index, darum hier fest auf die Headless-JSON-Templates zeigen: felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/' felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/' felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/' -# --- Structured data / schema.org (StructuredDataService) ------------------- -# Quellen 2026-11-09: Footer + Privacy Policy www.vitec.com (Adresse, Telefon, -# Social-Profile, Datencontroller "VITEC"); Rechtsform SA lt. KBIS Chatillon -# (Ablage 0029). E-Mail: oeffentlich keine -> leer. Logo folgt (fileadmin). seo.site.canonicalBase: 'https://www.vitec.com' -seo.site.name: 'VITEC' -seo.organization.name: 'VITEC' +seo.site.name: VITEC +seo.organization.name: VITEC seo.organization.legalName: 'VITEC SA' -seo.organization.logoUrl: '/fileadmin/vitec-brand/vitec-logo.png' +seo.organization.logoUrl: /fileadmin/vitec-brand/vitec-logo.png seo.organization.sameAs: 'https://www.linkedin.com/company/vitec-multimedia/, https://www.youtube.com/user/vitecmm' -seo.organization.email: '' seo.organization.phone: '+33 1 46 73 06 06' seo.organization.street: '99 rue Pierre Sémard' seo.organization.postalCode: '92320' -seo.organization.locality: 'Châtillon' -seo.organization.country: 'FR' +seo.organization.locality: Châtillon +seo.organization.country: FR +vitec.ssr.enabled: true + +# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap +# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09. +seo.sitemap.pages.excludePagesRecursive: '10,21,26,106' diff --git a/migrations/seo_fixes_20261109.php b/migrations/seo_fixes_20261109.php new file mode 100644 index 0000000..c2f54b3 --- /dev/null +++ b/migrations/seo_fixes_20261109.php @@ -0,0 +1,224 @@ +" / "" into 161 pages; TYPO3 emits a + * self-canonical anyway, and the pages sitemap EXCLUDES every page with + * a canonical_link (additionalWhere) - which is why markets, solutions, + * support, contact, insights and even the home page were missing from + * sitemap.xml. + * 2. no_index = 1 for technical pages (search, megamenu, footer shells...) + * and the imported old news pages under /en/news + /news (duplicates of + * the EXT:news articles). + * 3. 301 redirects for old-site URLs onto their new equivalents - the + * footer link targets plus every old news page that matches an EXT:news + * article by slug segment. + * 4. Rewrites the old-site hrefs inside tt_content bodytext (footer html + * columns) onto the new targets, so no page carries dead internal links. + * + * Dry run: php migrations/seo_fixes_20261109.php + * Write: php migrations/seo_fixes_20261109.php --apply + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +$apply = in_array('--apply', $argv, true); + +$root = null; +$dir = __DIR__; +for ($i = 0; $i < 5; $i++) { + if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } + $dir = dirname($dir); +} +if ($root === null) { exit("no project root\n"); } +$settings = require $root . '/config/system/settings.php'; +$db = $settings['DB']['Connections']['Default']; +$pdo = new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), + $db['user'] ?? '', $db['password'] ?? '', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n"; + +/* ------------------------------------------------------------------ */ +/* 1. Self-canonicals */ +/* ------------------------------------------------------------------ */ +// Comparison in PHP: CONCAT() in SQL trips over mixed column collations. +$selfCanonical = []; +foreach ($pdo->query("SELECT uid, canonical_link FROM pages WHERE deleted = 0 AND canonical_link <> ''") as $row) { + $uid = (int)$row['uid']; + $link = trim((string)$row['canonical_link']); + if ($link === 't3://page?uid=' . $uid || $link === (string)$uid) { + $selfCanonical[] = $uid; + } +} +echo "1. Self-Canonicals leeren: " . count($selfCanonical) . " Seiten\n"; +if ($apply && $selfCanonical !== []) { + $pdo->exec( + "UPDATE pages SET canonical_link = '', tstamp = UNIX_TIMESTAMP() + WHERE uid IN (" . implode(',', $selfCanonical) . ")" + ); +} + +/* ------------------------------------------------------------------ */ +/* 2. no_index for technical + old news pages */ +/* ------------------------------------------------------------------ */ +$technicalUids = [12, 16, 19, 106, 107, 448, 450, 516, 517, 519, 521, 523, 607]; +$in = implode(',', $technicalUids); +$cnt = (int)$pdo->query("SELECT COUNT(*) FROM pages WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)")->fetchColumn(); +echo "2. no_index=1 setzen: $cnt Seiten (technische Seiten + alte News-Seiten unter #106)\n"; +if ($apply && $cnt > 0) { + $pdo->exec("UPDATE pages SET no_index = 1, tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)"); +} + +/* ------------------------------------------------------------------ */ +/* 3a. Old-site URL mapping (footer targets) */ +/* ------------------------------------------------------------------ */ +$mapping = [ + '/about/careers' => '/insights/careers', + '/about/company' => '/insights/who-we-are', + '/about/events' => '/insights/events', + '/about/locations' => '/contact/locations', + '/about/news' => '/insights/news', + '/about/sustainability' => '/insights/sustainability', + '/about/who-we-are' => '/insights/who-we-are', + '/about/why-vitec' => '/insights/who-we-are', + '/downloads' => '/datasheets', + '/markets/accommodation' => '/markets/hospitality', + '/markets/broadcast' => '/markets/broadcast-and-telecoms', + '/markets/corporate' => '/markets/corporation', + '/markets/healthcare' => '/markets', + '/markets/military' => '/markets/defense-and-security', + '/markets/sports' => '/markets/sports-and-entertainment', + '/markets/venues' => '/markets/sports-and-entertainment', + '/products/encoders-decoders' => '/products/video-encoding-and-streaming', + '/products/encoders-decoders/appliances' => '/products/appliances', + '/products/encoders-decoders/modular-systems' => '/products/modular-systems', + '/products/encoders-decoders/modular-systems/avedia-encoders' => '/products/avedia-modular-system', + '/products/encoders-decoders/modular-systems/iptv-blade-system' => '/products/avedia-modular-system', + '/products/encoders-decoders/modular-systems/vitec-og' => '/products/modular-systems', + '/products/encoders-decoders/pcie-encoder' => '/products/activesqx', + '/products/encoders-decoders/rugged' => '/products/rugged-appliances', + '/products/ip/rf-gateways-transcoders' => '/products/rf-gateway', + '/products/ip/rf-gateways-transcoders/ip-gateways' => '/products/channellink-ip-gateway', + '/products/ip/rf-gateways-transcoders/prism-transcoder' => '/products/prism-transcoder', + '/products/ip/rf-gateways-transcoders/rf-gateways' => '/products/rf-gateway', + '/products/iptv-digital-signage-platform' => '/products/iptv-and-digital-signage', + '/products/iptv-digital-signage-platform/all-in-one-platform' => '/products/avedia', + '/products/iptv-digital-signage-platform/digital-signage' => '/products/iptv-and-digital-signage', + '/products/iptv-digital-signage-platform/end-points' => '/products/end-points', + '/products/iptv-digital-signage-platform/integrated-displays' => '/products/x-view-integrated-displays', + '/products/iptv-digital-signage-platform/interactive-tv-portal' => '/products/ez-tv', + '/products/iptv-digital-signage-platform/video-wall-controllers' => '/products/video-wall-controllers-and-management', + '/products/iptv-digital-signage-platform/video-wall-controllers/aligo' => '/products/aligo', + '/products/iptv-digital-signage-platform/video-wall-controllers/fx4-series' => '/products/video-wall-controllers-and-management', + '/products/iptv-digital-signage-platform/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers', + '/products/iptv-digital-signage-platform/x-player' => '/products/x-player-end-points', + '/products/services' => '/support', + '/products/services/deployment-services' => '/support/commissioning', + '/products/services/digital-signage-and-design-services' => '/support', + '/products/services/training' => '/support/training', + '/products/telemetry' => '/products', + '/solutions/contribution-remote-production' => '/solutions/video-capture-encoding-processing/contribution-remote-production', + '/solutions/iptv-distribution' => '/solutions/video-distribution-streaming/iptv-distribution', + '/solutions/situational-awareness-and-isr' => '/solutions/real-time-monitoring-situational-awareness', + '/solutions/video-streaming' => '/solutions/video-distribution-streaming', + '/support/material-compliance' => '/support/materials-compliance', + '/support/request-helpdesk-access' => '/support/login-to-helpdesk', + '/en/news' => '/insights/news', +]; + +// Resolve every target slug to a page uid (sanity: fail loudly on typos). +$targetUids = []; +$stmt = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1"); +$missing = 0; +foreach (array_unique(array_values($mapping)) as $target) { + $stmt->execute([$target]); + $uid = (int)$stmt->fetchColumn(); + if ($uid <= 0) { + echo " !! Zielseite fehlt: $target\n"; + $missing++; + continue; + } + $targetUids[$target] = $uid; +} +if ($missing > 0 && $apply) { + exit("Abbruch: $missing Zielseiten nicht gefunden - Mapping pruefen.\n"); +} + +echo "3a. Redirects Alt-URLs -> neue Seiten:\n"; +$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?"); +$insertRedirect = $pdo->prepare( + "INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode) + VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)" +); +$newRedirects = 0; +foreach ($mapping as $old => $new) { + if (!isset($targetUids[$new])) { continue; } + $hasRedirect->execute([$old]); + if ((int)$hasRedirect->fetchColumn() > 0) { continue; } + $newRedirects++; + if ($apply) { + $insertRedirect->execute([$old, 't3://page?uid=' . $targetUids[$new]]); + } +} +echo " $newRedirects neue Redirects (bestehende unangetastet)\n"; + +/* ------------------------------------------------------------------ */ +/* 3b. Old imported news pages -> EXT:news articles (slug match) */ +/* ------------------------------------------------------------------ */ +echo "3b. Alte News-Seiten -> /news/article/:\n"; +$oldNewsPages = $pdo->query( + "SELECT uid, slug FROM pages WHERE deleted = 0 AND (pid = 106 OR uid = 107) AND slug <> ''" +)->fetchAll(); +$newsBySegment = $pdo->prepare( + "SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND path_segment = ? LIMIT 1" +); +$matched = 0; $unmatched = []; +foreach ($oldNewsPages as $page) { + $segment = basename((string)$page['slug']); + $newsBySegment->execute([$segment]); + $newsUid = (int)$newsBySegment->fetchColumn(); + if ($newsUid <= 0) { + $unmatched[] = $page['slug']; + continue; + } + $hasRedirect->execute([$page['slug']]); + if ((int)$hasRedirect->fetchColumn() > 0) { continue; } + $matched++; + if ($apply) { + $insertRedirect->execute([(string)$page['slug'], '/news/article/' . $segment]); + } +} +echo " $matched Redirects auf Artikel, " . count($unmatched) . " ohne Artikel-Match (bleiben als noindex-Seiten erreichbar)\n"; +foreach ($unmatched as $slug) { + echo " kein Match: $slug\n"; +} + +/* ------------------------------------------------------------------ */ +/* 4. Dead hrefs inside tt_content bodytext */ +/* ------------------------------------------------------------------ */ +echo "4. Alt-Links in Inhalten ersetzen:\n"; +$findContent = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND bodytext LIKE ?"); +$fixContent = $pdo->prepare("UPDATE tt_content SET bodytext = REPLACE(bodytext, ?, ?), tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND bodytext LIKE ?"); +$replaced = 0; +foreach ($mapping as $old => $new) { + $needle = 'href="' . $old . '"'; + $findContent->execute(['%' . $needle . '%']); + $count = (int)$findContent->fetchColumn(); + if ($count === 0) { continue; } + echo " $needle -> href=\"$new\" ($count Element(e))\n"; + $replaced += $count; + if ($apply) { + $fixContent->execute([$needle, 'href="' . $new . '"', '%' . $needle . '%']); + } +} +echo " Summe: $replaced Element-Treffer\n"; + +echo $apply ? "FERTIG. Cache flushen (Redirects + Seiten sind gecacht).\n" : "DRY RUN beendet.\n"; diff --git a/migrations/set_stories_description_20261109.php b/migrations/set_stories_description_20261109.php new file mode 100644 index 0000000..7f91c43 --- /dev/null +++ b/migrations/set_stories_description_20261109.php @@ -0,0 +1,11 @@ +PDO::ERRMODE_EXCEPTION]); +$desc='Discover how organizations worldwide use VITEC and Datapath video solutions: customer success stories from broadcast, defense, enterprise, sports and public safety.'; +$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=4 AND (description IS NULL OR description='')"); +$st->execute([$desc]); +echo "Seite 4: ".$st->rowCount()." Zeile(n) aktualisiert\n"; diff --git a/packages/vitec/Classes/Seo/StoriesXmlSitemapDataProvider.php b/packages/vitec/Classes/Seo/StoriesXmlSitemapDataProvider.php new file mode 100644 index 0000000..023ab79 --- /dev/null +++ b/packages/vitec/Classes/Seo/StoriesXmlSitemapDataProvider.php @@ -0,0 +1,29 @@ +, but the + * public canonical URL is /success-stories/ (SuccessStoryPathRewrite + * middleware, old-site SEO URLs). The sitemap must list the canonical + * form, so the generated loc is rewritten accordingly. + */ +final class StoriesXmlSitemapDataProvider extends RecordsXmlSitemapDataProvider +{ + protected function defineUrl(array $data): array + { + $data = parent::defineUrl($data); + + if (isset($data['loc']) && is_string($data['loc'])) { + $data['loc'] = str_replace('/success-stories/story/', '/success-stories/', $data['loc']); + } + + return $data; + } +} diff --git a/packages/vitec/Classes/Seo/VitecMetaHandler.php b/packages/vitec/Classes/Seo/VitecMetaHandler.php index 293a8c5..1d6b9ca 100644 --- a/packages/vitec/Classes/Seo/VitecMetaHandler.php +++ b/packages/vitec/Classes/Seo/VitecMetaHandler.php @@ -4,20 +4,34 @@ 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; /** - * Mirrors the real page into `meta.title` of the page JSON. + * Completes the page JSON's `seo` block for the headless frontend. * - * Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page - * content, so on product/usecase detail pages it still shows the generic - * page title ("Product") - the record's seotitle only reaches the - * PageTitleProvider while the content renders. Headless fixes `seo.title` - * afterwards through this handler; we extend it so `meta.title` gets the - * same final value. Registered via the MetaHandlerInterface alias in - * Services.yaml, which covers both the cacheable listener and the - * USER_INT middleware path. + * 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 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 { @@ -25,6 +39,12 @@ class VitecMetaHandler extends MetaHandler { $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; @@ -32,4 +52,286 @@ class VitecMetaHandler extends MetaHandler 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((string)($news['bodytext'] ?? '')); + } + $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']); + } + + // --- 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; + } + + /** + * 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.,;:") . '...'; + } } diff --git a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php index 9e2c932..43eeec7 100644 --- a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php @@ -156,7 +156,7 @@ final class NewsJsonRenderer } $newsRows = $this->fetchNewsList($contentElement, $settings); - $items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows); + $items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows); $response = [ 'mode' => 'list', @@ -323,7 +323,7 @@ final class NewsJsonRenderer * @param array<string,mixed> $settings * @return array<string,mixed> */ - private function serializeNews(array $news, array $settings = []): array + private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array { $uid = (int)($news['uid'] ?? 0); $pathSegment = (string)($news['path_segment'] ?? ''); @@ -338,7 +338,14 @@ final class NewsJsonRenderer 'detailUrl' => $urls['detailUrl'], 'canonicalUrl' => $urls['canonicalUrl'], 'teaser' => (string)($news['teaser'] ?? ''), - 'bodytext' => RteResolver::html($news['bodytext'] ?? ''), + // List payloads do not carry the full article body: with ~600 + // records that inflated /_json/insights/news to 2.6 MB (and the + // SSR HTML to 2.8 MB). Cards render from `teaser`; only records + // WITHOUT a teaser get a plain-text excerpt in `bodytext` so no + // card goes blank. The detail mode is unchanged. + 'bodytext' => $mode === 'list' + ? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? ''))) + : RteResolver::html($news['bodytext'] ?? ''), 'datetime' => (int)($news['datetime'] ?? 0), 'archive' => (int)($news['archive'] ?? 0), 'istopnews' => (bool)($news['istopnews'] ?? false), @@ -353,6 +360,23 @@ final class NewsJsonRenderer ]; } + /** + * Plain-text excerpt of an RTE body for list cards (teaser fallback). + */ + private function listExcerpt(string $html): string + { + $text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html))); + if (mb_strlen($text) <= 300) { + return $text; + } + $cut = mb_substr($text, 0, 300); + $lastSpace = mb_strrpos($cut, ' '); + if ($lastSpace !== false && $lastSpace > 180) { + $cut = mb_substr($cut, 0, $lastSpace); + } + return rtrim($cut, " .,;:") . '...'; + } + /** * Where a news item actually points. * diff --git a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php index 80fc10e..e9dfec5 100755 --- a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php +++ b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php @@ -112,6 +112,53 @@ final class PageJsonLdRenderer + // Product on product detail pages (routed record, SEO audit 2026-11-09). + $args = $this->routeArguments($request); + $productArg = (string)($args['tx_vitec_productshow']['product'] ?? ''); + if ($productArg !== '') { + $productRow = $this->fetchDetailRecord( + 'tx_vitec_domain_model_product', + is_numeric($productArg) ? 'uid' : 'slug', + $productArg + ); + if ($productRow !== null) { + $productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'productimage', (int)$productRow['uid'], $base, $service); + if ($productImages === []) { + $productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'ogimage', (int)$productRow['uid'], $base, $service); + } + $nodes[] = $service->buildProduct([ + 'uid' => (int)$productRow['uid'], + 'title' => (string)($productRow['title'] ?? ''), + 'teaser' => (string)($productRow['teaser'] ?? ''), + 'description' => (string)($productRow['description'] ?? ''), + 'slug' => (string)($productRow['slug'] ?? ''), + ], $base, $productImages, $hasOrganization); + } + } + + // NewsArticle on EXT:news detail pages (the shell page carries no record data). + $newsUid = (int)($args['tx_news_pi1']['news'] ?? 0); + if ($newsUid > 0) { + $newsRow = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid); + if ($newsRow !== null) { + $newsTitle = trim((string)($newsRow['alternative_title'] ?? '')); + if ($newsTitle === '') { + $newsTitle = (string)($newsRow['title'] ?? ''); + } + $nodes[] = $service->buildNewsArticle([ + 'seo_title' => '', + 'title' => $newsTitle, + 'description' => trim(strip_tags((string)($newsRow['teaser'] ?? ''))), + 'abstract' => '', + 'crdate' => (int)($newsRow['datetime'] ?? 0), + 'SYS_LASTCHANGED' => (int)($newsRow['tstamp'] ?? 0), + 'slug' => '/news/article/' . (string)($newsRow['path_segment'] ?? ''), + 'author' => (string)($newsRow['author'] ?? ''), + 'author_email' => (string)($newsRow['author_email'] ?? ''), + ], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization); + } + } + return $service->encodeGraph($nodes); } catch (\Throwable $e) { return ''; @@ -410,4 +457,77 @@ final class PageJsonLdRenderer } } + /** + * Route arguments of the current request (record detail context). + * + * @return array<string,mixed> + */ + private function routeArguments($request): array + { + $routing = $request->getAttribute('routing'); + if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) { + return array_replace_recursive($request->getQueryParams(), $routing->getArguments()); + } + return $request->getQueryParams(); + } + + /** + * One visible record by uid or slug. Null when missing. + * + * @return array<string,mixed>|null + */ + private function fetchDetailRecord(string $table, string $field, string $value): ?array + { + try { + $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $row = $qb->select('*')->from($table) + ->where( + $qb->expr()->eq('deleted', 0), + $qb->expr()->eq('hidden', 0), + $field === 'uid' + ? $qb->expr()->eq('uid', $qb->createNamedParameter((int)$value, ParameterType::INTEGER)) + : $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::STRING)) + ) + ->setMaxResults(1)->executeQuery()->fetchAssociative(); + return is_array($row) ? $row : null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Absolute URLs of a record's FAL images (unprocessed originals). + * + * @return string[] + */ + private function recordImageUrls(string $table, string $field, int $uid, string $base, StructuredDataService $service): array + { + try { + $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference'); + $rows = $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') + ->executeQuery()->fetchAllAssociative(); + $urls = []; + foreach ($rows as $row) { + $identifier = (string)($row['identifier'] ?? ''); + if ($identifier !== '') { + $urls[] = $service->absUrl('/fileadmin' . $identifier, $base); + } + } + return $urls; + } catch (\Throwable $e) { + return []; + } + } + } diff --git a/packages/vitec/Configuration/Sets/Vitecset/config.yaml b/packages/vitec/Configuration/Sets/Vitecset/config.yaml index 1151776..6914d53 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/config.yaml +++ b/packages/vitec/Configuration/Sets/Vitecset/config.yaml @@ -11,3 +11,7 @@ dependencies: # solr must load before us so our plugin.tx_solr overrides (statistics, # highlighting, content extraction) survive - set order follows dependencies - apache-solr-for-typo3/solr + # sitemap sets must load before us so our plugin.tx_seo sitemap overrides + # (news pids, products/stories record sitemaps) survive + - typo3/seo-sitemap + - georgringer/news-sitemap diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 9227dcc..b454fb2 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -1017,3 +1017,59 @@ plugin.tx_solr.suggest { # recip(age) = 10 when new, ~5 after one year, ~0.7 after 13 years. # The gate keeps products/pages/stories out of the decay entirely. plugin.tx_solr.search.query.boostFunction = if(termfreq(type,'tx_news_domain_model_news'),recip(ms(NOW,created),3.16e-11,10,1),10) + + +# ====================================================================== +# XML-Sitemap (SEO-Audit 2026-11-09) +# +# - news: the georgringer/news-sitemap set ships pid=0 / pageId=0, which +# rendered an EMPTY news sitemap. Records live in folders 11 (Blog) and +# 442 (Press); the detail route sits on page 21 (/news/article/<slug>). +# - products: record sitemap for /product/<slug> detail pages (folder 30). +# - stories: record sitemap for success stories (folder 14) with the +# PUBLIC /success-stories/<slug> URLs (custom provider strips the +# internal /story/ segment). +# +# Requires vitecset to load AFTER typo3/seo-sitemap + georgringer/ +# news-sitemap - see the dependencies in this set's config.yaml. +# ====================================================================== +plugin.tx_seo.config.xmlSitemap.sitemaps { + news.config { + pid = 11,442 + url.pageId = 21 + } + + products { + provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider + config { + table = tx_vitec_domain_model_product + sortField = title + lastModifiedField = tstamp + pid = 30 + additionalWhere = {#hideonwebsite} = 0 + url { + pageId = 10 + fieldToParameterMap { + uid = tx_vitec_productshow[product] + } + } + } + } + + stories { + provider = Evomedien\Vitec\Seo\StoriesXmlSitemapDataProvider + config { + table = tx_vitec_domain_model_usecase + sortField = title + lastModifiedField = tstamp + pid = 14 + additionalWhere = {#hideonwebsite} = 0 AND {#no_index} = 0 + url { + pageId = 26 + fieldToParameterMap { + uid = tx_vitec_usecaseshow[usecase] + } + } + } + } +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c629c0f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /typo3/ +Disallow: /_json/ + +Sitemap: https://www.vitec.com/sitemap.xml