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 ); } 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 []; } } }