Files
orascheandClaude Fable 5.1 8a652063c5 Seobility-Report 2026-11-10: Backend-Befunde umgesetzt
Crawl nach dem Footer-Fix: 1.094 Seiten (vorher 9). Backend-Anteil:

- Migration seo_report_fixes_20261110: Platzhalter-SEO-Titel auf /solutions
  geleert; News mit verdoppeltem Titel/Slug und Slugs mit Slash bereinigt
  (+Redirects); 8 Tippfehler; zweite Welle toter Alt-Links (datapath.co.uk-
  Absolutlinks, utm-Varianten, /resource-center, /services, /contact-sales,
  /aetria-use-cases, /video-wall-solutions ...) in 48 News ersetzt + 101
  Redirects; Record-Shells /product, /news/article, /success-stories/story
  leiten auf ihre Uebersichten; Meta-Description /datasheets; WordPress-
  Reste in den 17 SEO-Artikeln entfernt; 53 News-Zeiger-Records (type=1
  auf alte /en/news-Seiten, ohne eigenen Text) bekommen den Seiteninhalt
  (Text + Bilder) in den Record und werden normale Artikel - die alten
  Seiten leiten per 301 auf die Artikel.
- VitecMetaHandler: kurze Seitentitel ohne SEO-Titel erhalten ihren
  Bereich als Kontext ('Aligo | AV over IP & KVM | VITEC').
- ProcessedFileReferenceToArray: Alt-Text-Fallback (Referenz-Titel, dann
  Dateititel) fuer Content-Block-Bilder.
- UsecaseListJsonRenderer: Testimonial-Baender liefern nur noch Stories
  mit Zitat, aktueller Markt zuerst, max. 12 (vorher alle 70 = 226 kB auf
  jeder Markt-/Solution-Unterseite, identische Bloecke auf ~90 Seiten).
- NewsJsonRenderer: paginierte Listen-Payloads 15 Min. im hash-Cache
  (USER_INT rendert sonst jede Variante neu; 50 langsame URLs im Report).
- robots.txt host-abhaengig: dev bekommt robots-dev.txt mit der dev-Sitemap
  (Seobility fand '0 Sitemaps', weil die Sitemap-Zeile auf www zeigte).

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

1252 lines
48 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Headless JSON renderer for EXT:news content elements.
*
* The renderer supports the news list/detail plugin variants used in this
* project and emits a stable JSON structure for the frontend.
*
* Exception-safe: returns '' on any failure.
*/
final class NewsJsonRenderer
{
private ?ContentObjectRenderer $cObj = null;
/**
* TYPO3 v14 hands the ContentObjectRenderer over through this setter only -
* ContentObjectRenderer::callUserFunction() duck-types it with
* is_callable([$classObj, 'setContentObjectRenderer']). Without the method
* $this->cObj stays null, the cObj branch of render() never fires and the
* call falls through to page discovery, which picks the FIRST element of
* this CType on the page rather than the one actually being rendered.
*/
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
private const NEWS_CTYPES = [
'news_pi1',
'news_newsliststicky',
'news_newsselectedlist',
'news_newsdetail',
'news_newsdatemenu',
'news_categorylist',
'news_newssearchform',
'news_newssearchresult',
'news_taglist',
];
private const TEMPLATE_LAYOUTS = [
'1' => 'Compact List',
'2' => 'Featured Story',
'3' => 'Editorial Grid',
'4' => 'Detail Spotlight',
];
private const TEMPLATE_LAYOUT_DEFAULTS = [
'news_pi1' => '1',
'news_newsliststicky' => '1',
'news_newsselectedlist' => '1',
'news_newsdetail' => '2',
'news_newsdatemenu' => '3',
'news_categorylist' => '3',
'news_newssearchform' => '4',
'news_newssearchresult' => '4',
'news_taglist' => '3',
];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$row = is_array($this->cObj?->data ?? null) ? $this->cObj->data : null;
if ($row !== null && in_array((string)($row['CType'] ?? ''), self::NEWS_CTYPES, true)) {
return $this->renderForRecord($row);
}
$pageId = $this->resolveCurrentPageId();
if ($pageId <= 0) {
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$ctypeConstraints = [];
foreach (self::NEWS_CTYPES as $ctype) {
$ctypeConstraints[] = $queryBuilder->expr()->eq(
'CType',
$queryBuilder->createNamedParameter($ctype, ParameterType::STRING)
);
}
$contentElements = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->or(...$ctypeConstraints),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ($contentElements === []) {
return '';
}
return $this->renderForRecord($contentElements[0]);
} catch (\Throwable $e) {
return '';
}
}
/**
* Render exactly the given news tt_content row.
*
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$type = (string)($contentElement['CType'] ?? '');
$mode = $type === 'news_newsdetail' ? 'detail' : 'list';
$debug = (bool)($settings['debug'] ?? false);
if ($mode === 'detail') {
$news = $this->resolveDetailNews($contentElement, $settings);
$response = [
'mode' => 'detail',
'news' => $news !== null ? $this->serializeNews($news, $settings) : null,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
if ($debug) {
$response['debug'] = [
'contentElementUid' => (int)($contentElement['uid'] ?? 0),
'settings' => $settings,
];
}
return json_encode($response) ?: '';
}
// Server-side pagination for the big overview list (contract
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
// WITHOUT a hand-picked selection paginates. That is exactly the
// /insights/news element (#130); every teaser list carries a
// limit (1-3) and keeps today's full behaviour, as do selected/
// search lists - the frontend falls back to client-side paging
// wherever the `pagination` key is absent.
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
&& (int)($settings['limit'] ?? 0) === 0
&& trim((string)($settings['selectedList'] ?? '')) === '';
if ($paginated
&& $this->cObj !== null
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
) {
// `page`/`category` sit in cacheHash.excludedParameters, so the
// TYPO3 page cache does NOT distinguish the query variants -
// a cacheable render would bake page 1 in for every URL. As
// USER_INT the element renders per request; EXT:headless wraps
// the INT marker itself (JsonContentObject detects
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
// variant per path+query, so per-request cost stays off the
// visitors' hot path.
$this->cObj->convertToUserIntObject();
return '';
}
$newsRows = $this->fetchNewsList($contentElement, $settings);
if ($paginated) {
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
}
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
$response = [
'mode' => 'list',
'items' => $items,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
if ($debug) {
$response['debug'] = [
'contentElementUid' => (int)($contentElement['uid'] ?? 0),
'count' => count($items),
'settings' => $settings,
];
}
return json_encode($response) ?: '';
} catch (\Throwable $e) {
return json_encode([
'mode' => 'error',
'message' => $e->getMessage(),
]) ?: '';
}
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>|null
*/
private function resolveDetailNews(array $contentElement, array $settings): ?array
{
$newsUid = $this->resolveNewsUid((string)($settings['singleNews'] ?? ''));
if ($newsUid <= 0) {
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$newsParam = $queryParams['tx_news_pi1']['news'] ?? null;
$newsUid = $this->resolveNewsUid((string)($newsParam ?? ''));
}
if ($newsUid > 0) {
$news = $this->fetchNewsByUid($newsUid, (int)($contentElement['sys_language_uid'] ?? 0));
if ($news !== null) {
return $news;
}
}
$list = $this->fetchNewsList($contentElement, $settings, 1);
return $list[0] ?? null;
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param int|null $limitOverride
* @return array<int,array<string,mixed>>
*/
private function fetchNewsList(array $contentElement, array $settings, ?int $limitOverride = null): array
{
$selectedNews = $this->parseUidList((string)($settings['selectedList'] ?? ''));
$storagePids = $this->parseUidList((string)($settings['startingpoint'] ?? ''));
if ($storagePids === []) {
$storagePids = [(int)($contentElement['pid'] ?? 0)];
}
$categoryUids = $this->parseUidList((string)($settings['categories'] ?? ''));
$includeSubCategories = (bool)($settings['includeSubCategories'] ?? false);
$tagUids = $this->parseUidList((string)($settings['tags'] ?? ''));
$limit = $limitOverride ?? (int)($settings['limit'] ?? 0);
$orderDirection = strtoupper((string)($settings['orderDirection'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$topNewsFirst = (bool)($settings['topNewsFirst'] ?? false);
$searchTerm = $this->resolveSearchTerm((string)($contentElement['CType'] ?? ''));
$languageUid = (int)($contentElement['sys_language_uid'] ?? 0);
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$rows = $queryBuilder
->select('n.*')
->from('tx_news_domain_model_news', 'n')
->where(
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0),
$queryBuilder->expr()->in('n.pid', $queryBuilder->createNamedParameter($storagePids, ArrayParameterType::INTEGER))
)
->orderBy('n.istopnews', $topNewsFirst ? 'DESC' : 'ASC')
->addOrderBy('n.datetime', 'DESC')
->executeQuery()
->fetchAllAssociative();
if ($languageUid > 0) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int)($row['sys_language_uid'] ?? 0) === $languageUid));
} else {
$rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)($row['sys_language_uid'] ?? 0), [0, -1], true)));
}
if ($selectedNews !== []) {
$selectedLookup = array_fill_keys($selectedNews, true);
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($selectedLookup[(int)($row['uid'] ?? 0)])));
}
if ($categoryUids !== []) {
$categoryLookup = $this->newsUidsForCategories($categoryUids, $includeSubCategories);
if ($categoryLookup === [] && !$includeSubCategories) {
$categoryLookup = $this->newsUidsForCategories($this->expandCategoryUids($categoryUids), true);
}
if ($categoryLookup !== []) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($categoryLookup[(int)($row['uid'] ?? 0)])));
}
}
if ($tagUids !== []) {
$tagLookup = $this->newsUidsForTags($tagUids);
if ($tagLookup !== []) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($tagLookup[(int)($row['uid'] ?? 0)])));
}
}
if ($searchTerm !== '') {
$needle = mb_strtolower($searchTerm);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
$haystack = mb_strtolower((string)($row['title'] ?? '') . ' ' . (string)($row['teaser'] ?? '') . ' ' . (string)($row['path_segment'] ?? ''));
return str_contains($haystack, $needle);
}));
}
if ($limit > 0) {
$rows = array_slice($rows, 0, $limit);
}
return $rows;
}
/**
* @param int $newsUid
* @param int $languageUid
* @return array<string,mixed>|null
*/
private function fetchNewsByUid(int $newsUid, int $languageUid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$query = $queryBuilder
->select('n.*')
->from('tx_news_domain_model_news', 'n')
->where(
$queryBuilder->expr()->eq('n.uid', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0)
);
if ($languageUid > 0) {
$query->andWhere(
$queryBuilder->expr()->eq('n.sys_language_uid', $queryBuilder->createNamedParameter($languageUid, ParameterType::INTEGER))
);
}
$news = $query->executeQuery()->fetchAssociative();
return is_array($news) ? $news : null;
}
/**
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array
{
$uid = (int)($news['uid'] ?? 0);
$pathSegment = (string)($news['path_segment'] ?? '');
$urls = $this->buildNewsUrls($news, $settings);
// Detail view only (contract 0083-BE): the SEO articles carry a
// "Frequently asked questions" section as bare <p> pairs plus inline
// product links. The Q&A pairs move into a structured `faq` band
// (accordion in the frontend), linked products/categories become
// `mentionedProducts` cards; the body keeps everything else.
$detailBody = null;
$faqBand = null;
$mentionedProducts = [];
if ($mode !== 'list') {
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
$faqSplit = $this->extractFaqBand($detailBody);
$detailBody = $faqSplit['html'];
$faqBand = $faqSplit['faq'];
$mentionedProducts = $this->collectMentionedProducts($detailBody);
}
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'pathSegment' => $pathSegment,
'type' => (string)($news['type'] ?? '0'),
'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''),
// 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.
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
// paragraph) are split out of the body - they reach Google via
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: (string)$detailBody,
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'author' => (string)($news['author'] ?? ''),
'authorEmail' => (string)($news['author_email'] ?? ''),
'categories' => $this->resolveCategories($uid),
'tags' => $this->resolveTags($uid),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
'related' => $this->resolveRelatedNews($uid),
'relatedLinks' => $this->resolveRelatedLinks($uid),
'faq' => $faqBand,
'mentionedProducts' => $mentionedProducts,
];
}
/**
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
* page's cards plus pagination meta, filter chips with full-list counts
* and the active category. Request: ?page=<n>&category=<slug> on the page
* URL; page is clamped into [1..pages] (page > pages serves the last
* page), an unknown category slug falls back to "all".
*
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
*/
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
{
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
// USER_INT renders on every request; the 12-card page payload (image
// processing included) took 1-6 s on cold SSR misses (Seobility: 50
// slow URLs, all ?page=N). Keep each page/category variant for 15 min
// in the hash cache - cache:flush clears it, news edits are rare.
$cache = null;
$cacheId = '';
try {
$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('hash');
$cacheId = 'vitec-newslist-' . (int)($contentElement['uid'] ?? 0)
. '-' . (int)($contentElement['sys_language_uid'] ?? 0)
. '-' . $requestedPage . '-' . md5($requestedCategory);
$cached = $cache->get($cacheId);
if (is_string($cached) && $cached !== '') {
return $cached;
}
} catch (\Throwable $e) {
$cache = null;
}
// All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels.
$categoriesByNews = $this->categoriesForNewsUids(
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
);
$categoryIndex = [];
foreach ($rows as $row) {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
$slug = $this->slugify((string)$category['title']);
if ($slug === '') {
continue;
}
if (!isset($categoryIndex[$slug])) {
$categoryIndex[$slug] = [
'uid' => (int)$category['uid'],
'title' => (string)$category['title'],
'slug' => $slug,
'count' => 0,
];
}
$categoryIndex[$slug]['count']++;
}
}
ksort($categoryIndex);
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
if ($activeCategory !== '') {
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
if ($this->slugify((string)$category['title']) === $activeCategory) {
return true;
}
}
return false;
}));
}
// Newest first, server-side (the flexform historically says
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
if ($itemsPerPage <= 0) {
$itemsPerPage = 12;
}
$total = count($rows);
$pages = max(1, (int)ceil($total / $itemsPerPage));
$page = min($requestedPage, $pages);
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
$items = array_map(
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
$pageRows
);
$response = [
'mode' => 'list',
'items' => $items,
'pagination' => [
'page' => $page,
'pages' => $pages,
'itemsPerPage' => $itemsPerPage,
'total' => $total,
],
'categories' => array_values($categoryIndex),
'activeCategory' => $activeCategory,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
$json = json_encode($response) ?: '';
if ($cache !== null && $json !== '') {
try {
$cache->set($cacheId, $json, ['tx_news'], 900);
} catch (\Throwable $e) {
// caching is best effort
}
}
return $json;
}
/**
* Lean card shape for paginated pages - only what the cards render
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
* deliberately absent here.
*
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $categories
* @return array<string,mixed>
*/
private function serializeListCard(array $news, array $settings, array $categories): array
{
$uid = (int)($news['uid'] ?? 0);
$urls = $this->buildNewsUrls($news, $settings);
$teaser = trim((string)($news['teaser'] ?? ''));
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'type' => (string)($news['type'] ?? '0'),
'detailUrl' => $urls['detailUrl'],
'datetime' => (int)($news['datetime'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'teaser' => $teaser,
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
'categories' => array_map(
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
$categories
),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
];
}
/**
* uid => categories map for a set of news records, resolved in one query
* (the per-record resolveCategories() would fire ~600 queries here).
*
* @param list<int> $newsUids
* @return array<int,array<int,array{uid:int,title:string}>>
*/
private function categoriesForNewsUids(array $newsUids): array
{
$newsUids = array_values(array_filter($newsUids));
if ($newsUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.uid', 'c.title', 'mm.uid_foreign')
->from('sys_category', 'c')
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid_foreign']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
];
}
return $map;
}
/**
* Category title -> filter slug ("Press Release" -> "press-release"),
* mirroring the slugs the page URLs already carry.
*/
private function slugify(string $value): string
{
$value = mb_strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
return trim($value, '-');
}
/**
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
*/
private function listExcerpt(string $html): string
{
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
$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, " .,;:") . '...';
}
/**
* Splits the trailing FAQ section out of an article body.
*
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
* with "Frequently asked" or "FAQ", followed by alternating question
* paragraphs (plain text ending in "?") and answer paragraphs (may
* contain links). Fail-safe: anything that does not yield at least two
* clean Q&A pairs leaves the HTML untouched.
*
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
*/
private function extractFaqBand(string $html): array
{
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
return ['html' => $html, 'faq' => null];
}
$heading = trim((string)$headingMatch[2][0]);
$sectionStart = (int)$headingMatch[0][1];
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
$sectionEnd = $cursor;
$items = [];
$question = null;
$answerParts = [];
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
$text = trim(strip_tags($paragraph[1]));
if ($text === '') {
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
continue;
}
if (str_ends_with($text, '?')) {
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
$question = $text;
$answerParts = [];
} elseif ($question !== null) {
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
} else {
break;
}
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
}
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
if (count($items) < 2) {
return ['html' => $html, 'faq' => null];
}
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
return [
'html' => trim($clean),
'faq' => ['heading' => $heading, 'items' => $items],
];
}
/**
* Cards for every product detail page (/product/<slug>) and product
* category page (/products/<slug>) linked inside an article body -
* order of first appearance, deduplicated, at most six.
*
* @return array<int,array<string,mixed>>
*/
private function collectMentionedProducts(string $html): array
{
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
return [];
}
$cards = [];
foreach ($matches as $match) {
$href = (string)$match[1];
if (isset($cards[$href]) || count($cards) >= 6) {
continue;
}
try {
if (!empty($match[2])) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
->from('tx_vitec_domain_model_product')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('hideonwebsite', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'product',
'title' => (string)($row['title'] ?? ''),
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
'link' => $href,
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
];
} else {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $qb->select('uid', 'title', 'description', 'abstract')
->from('pages')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'category',
'title' => (string)($row['title'] ?? ''),
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
'link' => $href,
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
];
}
} catch (\Throwable $e) {
continue;
}
}
return array_values($cards);
}
/**
* Where a news item actually points.
*
* EXT:news knows three record types, and only the first one lives on the
* news detail route:
*
* 0 article -> detail page + path_segment (the default below)
* 1 internal page -> the page picked in the backend; the record itself
* carries no body, so linking it to the detail route
* would show a teaser and hide the actual content
* 2 external url -> the given address, verbatim
*
* `detailUrl` is rewritten rather than accompanied by a second field, so a
* consumer can keep following one key for every item and never has to branch
* on the type. `type` is emitted alongside anyway — a front end may well want
* to open an external target in a new tab.
*
* Falls back to the article URL whenever a type 1/2 record does not yield a
* usable target: a link to the teaser beats a dead one.
*
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @return array{detailUrl:string, canonicalUrl:string}
*/
private function buildNewsUrls(array $news, array $settings): array
{
$type = (string)($news['type'] ?? '0');
if ($type === '2') {
$external = trim((string)($news['externalurl'] ?? ''));
if ($external !== '') {
return ['detailUrl' => $external, 'canonicalUrl' => $external];
}
}
if ($type === '1') {
$pageUid = $this->extractPageUid((string)($news['internalurl'] ?? ''));
$pageUrl = $pageUid > 0 ? LinkResolver::pageUrl($pageUid) : null;
if ($pageUrl !== null && $pageUrl !== '') {
return [
'detailUrl' => $pageUrl,
'canonicalUrl' => $this->absoluteUrl($pageUrl),
];
}
}
$pathSegment = trim((string)($news['path_segment'] ?? ''), '/');
if ($pathSegment === '') {
return ['detailUrl' => '', 'canonicalUrl' => ''];
}
$detailPid = (int)($settings['detailPid'] ?? 0);
if ($detailPid <= 0) {
$detailPid = $this->resolveCurrentPageId();
}
$detailPageSlug = $detailPid > 0 ? $this->resolvePageSlug($detailPid) : '';
if ($detailPageSlug === '') {
return ['detailUrl' => '', 'canonicalUrl' => ''];
}
$detailUrl = rtrim($detailPageSlug, '/') . '/' . $pathSegment;
$canonicalUrl = $this->absoluteUrl($detailUrl);
return [
'detailUrl' => $detailUrl,
'canonicalUrl' => $canonicalUrl,
];
}
/**
* `internalurl` holds either `t3://page?uid=N` or a bare uid, depending on
* how old the record is - the legacy import produced both forms.
*/
private function extractPageUid(string $internalUrl): int
{
$internalUrl = trim($internalUrl);
if (preg_match('#t3://page\?uid=(\d+)#i', $internalUrl, $m)) {
return (int)$m[1];
}
return ctype_digit($internalUrl) ? (int)$internalUrl : 0;
}
private function resolvePageSlug(int $pageId): string
{
if ($pageId <= 0) {
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $queryBuilder
->select('slug')
->from('pages')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
return (string)($row['slug'] ?? '');
}
private function absoluteUrl(string $path): string
{
$path = '/' . ltrim($path, '/');
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/') . $path;
}
}
$site = $request?->getAttribute('site');
if ($site !== null) {
return rtrim((string)$site->getBase(), '/') . $path;
}
return $path;
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveCategories(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
return $queryBuilder
->select('c.uid', 'c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING))
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveTags(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_tag');
return $queryBuilder
->select('t.uid', 't.title')
->from('tx_news_domain_model_tag', 't')
->join(
't',
'tx_news_domain_model_news_tag_mm',
'mm',
'mm.uid_foreign = t.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER))
)
->orderBy('t.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @param list<int> $categoryUids
* @return array<int,true>
*/
private function newsUidsForCategories(array $categoryUids, bool $includeSubCategories): array
{
if ($categoryUids === []) {
return [];
}
$categories = $includeSubCategories ? $this->expandCategoryUids($categoryUids) : $categoryUids;
if ($categories === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category_record_mm');
$uids = $queryBuilder
->select('uid_foreign')
->from('sys_category_record_mm')
->where(
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn();
return array_fill_keys(array_map('intval', $uids), true);
}
/**
* @param list<int> $tagUids
* @return array<int,true>
*/
private function newsUidsForTags(array $tagUids): array
{
if ($tagUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news_tag_mm');
$uids = $queryBuilder
->select('uid_local')
->from('tx_news_domain_model_news_tag_mm')
->where(
$queryBuilder->expr()->in('uid_foreign', $queryBuilder->createNamedParameter($tagUids, ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn();
return array_fill_keys(array_map('intval', $uids), true);
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveRelatedNews(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
return $queryBuilder
->select('n.uid', 'n.title', 'n.path_segment')
->from('tx_news_domain_model_news', 'n')
->join(
'n',
'tx_news_domain_model_news_related_mm',
'mm',
'mm.uid_foreign = n.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0)
)
->orderBy('n.datetime', 'DESC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveRelatedLinks(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_link');
return $queryBuilder
->select('l.uid', 'l.title', 'l.description', 'l.uri')
->from('tx_news_domain_model_link', 'l')
->where(
$queryBuilder->expr()->eq('l.parent', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('l.deleted', 0),
$queryBuilder->expr()->eq('l.hidden', 0)
)
->orderBy('l.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveFiles(string $tableName, string $fieldName, int $uid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$rows = $queryBuilder
->select('uid')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($tableName, ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ($rows === []) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$files = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1400, 'fileExtension' => 'webp']);
$files[] = [
'uid' => (int)$row['uid'],
'url' => $imageService->getImageUri($processed),
// Editor-maintained alt text; the frontend falls back to
// the article title when this is empty (0070-FE, B5).
'alternative' => (string)($fileReference->getAlternative() ?? ''),
];
} catch (\Throwable $e) {
continue;
}
}
return $files;
}
private function resolveNewsUid(string $rawValue): int
{
$rawValue = trim($rawValue);
if ($rawValue === '') {
return 0;
}
if (ctype_digit($rawValue)) {
return (int)$rawValue;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$row = $queryBuilder
->select('uid')
->from('tx_news_domain_model_news')
->where(
$queryBuilder->expr()->eq('path_segment', $queryBuilder->createNamedParameter($rawValue, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
return (int)($row['uid'] ?? 0);
}
/**
* @return list<int>
*/
private function parseUidList(string $rawValue): array
{
$values = array_filter(array_map('trim', explode(',', $rawValue)));
return array_values(array_filter(array_map(static fn (string $value): int => (int)$value, $values)));
}
/**
* @param list<int> $categoryUids
* @return list<int>
*/
private function expandCategoryUids(array $categoryUids): array
{
if ($categoryUids === []) {
return [];
}
$allUids = array_fill_keys($categoryUids, true);
$queue = $categoryUids;
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
while ($queue !== []) {
$currentUid = array_shift($queue);
if ($currentUid === null) {
continue;
}
$children = $queryBuilder
->select('uid')
->from('sys_category')
->where(
$queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($currentUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchFirstColumn();
foreach ($children as $childUid) {
$childUid = (int)$childUid;
if ($childUid <= 0 || isset($allUids[$childUid])) {
continue;
}
$allUids[$childUid] = true;
$queue[] = $childUid;
}
}
return array_values(array_map('intval', array_keys($allUids)));
}
private function resolveCurrentPageId(): int
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
return (int)$pageInfo->getId();
}
}
return (int)($GLOBALS['TSFE']->id ?? 0);
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function buildSettingsPayload(array $contentElement, array $settings): array
{
$payload = $settings;
$payload['type'] = (string)($contentElement['CType'] ?? '');
$payload['detailPid'] = (int)($settings['detailPid'] ?? 0);
$payload['listPid'] = (int)($settings['listPid'] ?? 0);
$payload['templateLayout'] = $this->resolveTemplateLayoutValue((string)($contentElement['CType'] ?? ''), (string)($settings['templateLayout'] ?? ''));
$payload['templateLayoutLabel'] = self::TEMPLATE_LAYOUTS[$payload['templateLayout']] ?? '';
$payload['templateLayouts'] = array_map(
static fn (string $label, string $key): array => ['key' => $key, 'label' => $label],
self::TEMPLATE_LAYOUTS,
array_keys(self::TEMPLATE_LAYOUTS)
);
return $payload;
}
private function resolveTemplateLayoutValue(string $type, string $value): string
{
$value = trim($value);
if ($value !== '' && isset(self::TEMPLATE_LAYOUTS[$value])) {
return $value;
}
return self::TEMPLATE_LAYOUT_DEFAULTS[$type] ?? '';
}
private function resolveSearchTerm(string $type): string
{
if ($type !== 'news_newssearchresult') {
return '';
}
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$search = $queryParams['tx_news_pi1']['search']['sword'] ?? '';
return trim((string)$search);
}
}