414 lines
16 KiB
PHP
Executable File
414 lines
16 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\UserFunc;
|
|
|
|
use Evomedien\Vitec\Service\StructuredDataService;
|
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use Doctrine\DBAL\ParameterType;
|
|
|
|
/**
|
|
* Emits the page-level schema.org @graph (Organization, WebSite,
|
|
* BreadcrumbList, NewsArticle) as a JSON-LD string for the headless page
|
|
* object. Wired in setup.typoscript as:
|
|
*
|
|
* page.10.fields.jsonLd = USER
|
|
* page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
|
*
|
|
* The JS frontend wraps the returned string in
|
|
* `<script type="application/ld+json">…</script>` inside the page <head>.
|
|
*
|
|
* 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<string,mixed>
|
|
*/
|
|
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<int,array<string,mixed>> $rootLine
|
|
* @return list<array{name:string, url:string}>
|
|
*/
|
|
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<array{question:string,answer:string}>
|
|
*/
|
|
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<array<string,mixed>>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
}
|