SEO-Audit 2026-11-09: Meta-Komposition, Sitemap, Redirects, robots.txt
- VitecMetaHandler: Record-Metadaten fuer News-/Produkt-/Story-Details (Titel, Description, og:/twitter:-Tags, og:image), '| VITEC'-Suffix, Fallback-Description aus Seitenfeldern, twitter:card large_image, Story-Canonical auf die oeffentliche Kurz-URL - PageJsonLdRenderer: Product- und NewsArticle-Nodes auf Detailseiten (News-Details hatten <title>article</title> und kein Schema) - NewsJsonRenderer: Listen ohne Volltext-Body (JSON 2.6 MB -> ~0.6 MB) - XML-Sitemap: News-Teil konfiguriert (Ordner 11+442, Detailseite 21), Record-Sitemaps fuer Produkte und Success Stories (kurze URLs), Route-Enhancer fuer alle Teile, Shell-/Altseiten ausgeschlossen - Migration seo_fixes_20261109: 161 Self-Canonicals geleert (warfen u.a. Home, Markets, Solutions, Support aus der Sitemap), noindex fuer technische und alte News-Seiten, 71 Redirects Alt-URLs -> neue Ziele, 51 tote Footer-Links ersetzt - public/robots.txt mit Sitemap-Verweis Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be519f9bc1
commit
b9736b9ac7
11 files changed
+814
-25
No files matched your search
@@ -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=<name> 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:
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SEO fixes (audit 2026-11-09):
|
||||
*
|
||||
* 1. Clear SELF-referencing canonical_link values. The page imports wrote
|
||||
* "t3://page?uid=<self>" / "<self>" 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/<slug>:\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";
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// Setzt die fehlende Meta-Description der Success-Stories-Uebersicht (Seite 4).
|
||||
// Fill-up only: eine vorhandene Description wird nicht ueberschrieben.
|
||||
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
|
||||
$s=require $root.'/config/system/settings.php';$db=$s['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]);
|
||||
$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";
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Seo;
|
||||
|
||||
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider;
|
||||
|
||||
/**
|
||||
* Success-story sitemap entries with their PUBLIC URLs.
|
||||
*
|
||||
* The route enhancer generates /success-stories/story/<slug>, but the
|
||||
* public canonical URL is /success-stories/<slug> (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;
|
||||
}
|
||||
}
|
||||
@@ -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 <title> 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</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
|
||||
{
|
||||
@@ -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.,;:") . '...';
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /typo3/
|
||||
Disallow: /_json/
|
||||
|
||||
Sitemap: https://www.vitec.com/sitemap.xml
|
||||
Reference in new issue
Block a user