render
*
* The JS frontend wraps the returned string in
* `` inside the page
.
*
* Organization data is pulled from site settings (category "seo"); the
* canonical absolute base URL likewise (with a request-host fallback) because
* the headless site base is "/".
*
* Exception-safe: returns '' on any failure, never breaks the page JSON.
*/
final class PageJsonLdRenderer
{
/** News-detail backend layouts (see BackendLayoutDataProvider::LAYOUT_MAP). */
private const NEWS_DETAIL_LAYOUTS = [13, 14, 15];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null) {
return '';
}
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo === null) {
return '';
}
$pageId = (int)$pageInfo->getId();
$pageRecord = $pageInfo->getPageRecord();
$rootLine = $pageInfo->getRootLine();
$site = $request->getAttribute('site');
$settings = $site?->getSettings();
$base = $this->resolveCanonicalBase($request, $settings);
$service = GeneralUtility::makeInstance(StructuredDataService::class);
$orgCfg = $this->organizationConfig($settings, $base);
$hasOrganization = ($orgCfg['name'] ?? '') !== '';
$nodes = [];
// Organization on every page (anchors @id references).
if ($hasOrganization) {
$nodes[] = $service->buildOrganization($orgCfg);
}
// WebSite only on the site root / home page.
$isHome = $site !== null && $pageId === (int)$site->getRootPageId();
if ($isHome) {
$siteName = (string)($settings?->get('seo.site.name') ?? '');
if ($siteName === '') {
$siteName = (string)($orgCfg['name'] ?? '');
}
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
}
// BreadcrumbList from the rootline.
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base);
if ($breadcrumb !== null) {
$nodes[] = $breadcrumb;
}
// NewsArticle on news-detail pages.
if (in_array((int)($pageRecord['layout'] ?? 0), self::NEWS_DETAIL_LAYOUTS, true)) {
$nodes[] = $service->buildNewsArticle(
$pageRecord,
$base,
$this->pageMediaUrls($pageId, $base, $service),
$hasOrganization
);
}
// FAQPage when the page contains at least one vitec_faq element.
$faqItems = $this->collectFaqItems($pageId);
if ($faqItems !== []) {
$nodes[] = $service->buildFaqPage($faqItems, $base);
}
// Event nodes when the page contains a vitec_eventlist element.
foreach ($this->collectUpcomingEvents($pageId) as $eventRow) {
$nodes[] = $service->buildEvent(
$eventRow,
$base,
$this->eventImageUrls((int)$eventRow['uid'], $base, $service),
$hasOrganization
);
}
// 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);
// Editor-pasted schema blocks (e.g. FAQPage JSON in a
// trailing bodytext paragraph) join the @graph here;
// NewsJsonRenderer strips them out of the rendered body.
foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) {
$nodes[] = $embeddedNode;
}
}
}
return $service->encodeGraph($nodes);
} catch (\Throwable $e) {
return '';
}
}
/**
* Canonical absolute base, e.g. "https://www.vitec.com".
* Prefers the explicit site setting, falls back to the request host.
*/
private function resolveCanonicalBase($request, $settings): string
{
$configured = trim((string)($settings?->get('seo.site.canonicalBase') ?? ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
/**
* Assemble the Organization config from site settings.
*
* @return array
*/
private function organizationConfig($settings, string $base): array
{
$get = static fn (string $key): string => trim((string)($settings?->get($key) ?? ''));
$sameAsRaw = $get('seo.organization.sameAs');
$sameAs = $sameAsRaw !== ''
? array_filter(array_map('trim', preg_split('/[\r\n,]+/', $sameAsRaw) ?: []))
: [];
return [
'name' => $get('seo.organization.name'),
'legalName' => $get('seo.organization.legalName'),
'base' => $base,
'logoUrl' => $get('seo.organization.logoUrl'),
'sameAs' => array_values($sameAs),
'email' => $get('seo.organization.email'),
'phone' => $get('seo.organization.phone'),
'street' => $get('seo.organization.street'),
'postalCode' => $get('seo.organization.postalCode'),
'locality' => $get('seo.organization.locality'),
'country' => $get('seo.organization.country'),
];
}
/**
* Build ordered breadcrumb items (root → current) from the rootline.
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
*
* @param array> $rootLine
* @return list
*/
private function breadcrumbItems(array $rootLine): array
{
$skipDoktypes = [199, 254, 255];
$items = [];
// Rootline is ordered current → root; reverse for breadcrumb order.
foreach (array_reverse($rootLine) as $page) {
$doktype = (int)($page['doktype'] ?? 1);
if (in_array($doktype, $skipDoktypes, true)) {
continue;
}
// Matches the visible headless breadcrumb (HMENU special=rootline),
// which keeps nav_hide pages for path integrity.
$name = trim((string)($page['nav_title'] ?? '')) ?: trim((string)($page['title'] ?? ''));
if ($name === '') {
continue;
}
$items[] = [
'name' => $name,
'url' => (string)($page['slug'] ?? ''),
];
}
return $items;
}
/**
* Absolute URLs of the page's `media` images (used as NewsArticle image).
*
* @return string[]
*/
private function pageMediaUrls(int $pageId, string $base, StructuredDataService $service): 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($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('pages', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('media', 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);
$urls = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1200]);
$urls[] = $service->absUrl($imageService->getImageUri($processed), $base);
} catch (\Throwable $e) {
continue;
}
}
return $urls;
}
/**
* Collect question/answer pairs from all visible vitec_faq elements on
* this page (Content Blocks collection child table).
*
* @return list
*/
private function collectFaqItems(int $pageId): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$elements = $qb
->select('uid')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_faq', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if ($elements === []) {
return [];
}
$items = [];
foreach ($elements as $element) {
$childQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('vitec_faq_items');
$rows = $childQb
->select('*')
->from('vitec_faq_items')
->where(
$childQb->expr()->eq('foreign_table_parent_uid', $childQb->createNamedParameter((int)$element['uid'], ParameterType::INTEGER)),
$childQb->expr()->eq('deleted', 0),
$childQb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($rows as $row) {
$items[] = [
'question' => (string)($row['question'] ?? $row['vitec_question'] ?? ''),
'answer' => (string)($row['answer'] ?? $row['vitec_answer'] ?? ''),
];
}
}
return $items;
} catch (\Throwable $e) {
// Table may not exist yet (schema not updated) — skip silently.
return [];
}
}
/**
* Upcoming event rows when this page contains at least one visible
* vitec_eventlist element. Empty array otherwise.
*
* @return list>
*/
private function collectUpcomingEvents(int $pageId): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$eventlistElement = $qb
->select('uid', 'pi_flexform')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_eventlist', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$eventlistElement) {
return [];
}
// Mirror the plugin's "days in advance" window so structured data
// matches what the page actually shows.
$daysInAdvance = 0;
$flexFormService = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Service\FlexFormService::class);
$flexSettings = $flexFormService->convertFlexFormContentToArray((string)($eventlistElement['pi_flexform'] ?? ''))['settings'] ?? [];
$daysInAdvance = (int)($flexSettings['daysinadvance'] ?? 0);
$todayMidnight = strtotime('today');
$eventQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_event');
$expr = $eventQb->expr();
$eventQb
->select('*')
->from('tx_vitec_domain_model_event')
->where(
$expr->eq('deleted', 0),
$expr->eq('hidden', 0),
$expr->eq('hideonwebsite', 0),
$expr->or(
$expr->gte('eventend', $eventQb->createNamedParameter($todayMidnight, ParameterType::INTEGER)),
$expr->and(
$expr->eq('eventend', 0),
$expr->gte('eventstart', $eventQb->createNamedParameter($todayMidnight, ParameterType::INTEGER))
)
)
)
->orderBy('eventstart', 'ASC');
if ($daysInAdvance > 0) {
$eventQb->andWhere(
$expr->lt('eventstart', $eventQb->createNamedParameter($todayMidnight + ($daysInAdvance * 86400), ParameterType::INTEGER))
);
}
return $eventQb->executeQuery()->fetchAllAssociative();
} catch (\Throwable $e) {
// Table may not exist yet — skip silently.
return [];
}
}
/**
* Root-relative logo URLs for an event (fieldname=image).
*
* @return list
*/
private function eventImageUrls(int $eventUid, string $base, $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('tx_vitec_domain_model_event', ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('image', ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('f.missing', 0)
)
->executeQuery()
->fetchAllAssociative();
$urls = [];
foreach ($rows as $row) {
$identifier = (string)($row['identifier'] ?? '');
if ($identifier !== '') {
$urls[] = '/fileadmin' . $identifier;
}
}
return $urls;
} catch (\Throwable $e) {
return [];
}
}
/**
* Route arguments of the current request (record detail context).
*
* @return array
*/
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|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 [];
}
}
}