Files
VITEC-website/packages/vitec/Classes/Service/StructuredDataService.php
2026-07-09 10:10:05 +02:00

513 lines
17 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Builds schema.org JSON-LD structures (as PHP arrays) for the headless
* frontend. Each builder is pure: it takes primitive input and returns an
* array, so it can be unit-tested without TYPO3 runtime state.
*
* The consuming renderers (PageJsonLdRenderer, ProductShowJsonRenderer) gather
* the request/site context and call these builders, then json_encode the
* resulting @graph into a `<script type="application/ld+json">` payload that
* the JS frontend injects into the page <head>.
*
* Conventions:
* - Absolute URLs everywhere (Google requirement). Use {@see absUrl()}.
* - Stable @id anchors per entity so nodes can reference each other across
* the different scripts on a page (Organization is the central publisher).
*/
final class StructuredDataService
{
public const ORGANIZATION_FRAGMENT = '#organization';
public const WEBSITE_FRAGMENT = '#website';
/**
* schema.org/Organization — the central publisher node. Emitted on every
* page so its @id can be referenced by WebSite, Article and Product.
*
* @param array{
* name?:string, legalName?:string, base:string, logoUrl?:string,
* sameAs?:string[], email?:string, phone?:string,
* street?:string, postalCode?:string, locality?:string, country?:string
* } $cfg
* @return array<string,mixed>
*/
public function buildOrganization(array $cfg): array
{
$base = $this->normalizeBase($cfg['base'] ?? '/');
$org = [
'@type' => 'Organization',
'@id' => $base . self::ORGANIZATION_FRAGMENT,
'name' => (string)($cfg['name'] ?? ''),
'url' => $base . '/',
];
if (!empty($cfg['legalName'])) {
$org['legalName'] = (string)$cfg['legalName'];
}
if (!empty($cfg['logoUrl'])) {
$logo = $this->absUrl((string)$cfg['logoUrl'], $base);
$org['logo'] = [
'@type' => 'ImageObject',
'url' => $logo,
];
// Google reuses logo as the Organization image fallback.
$org['image'] = $logo;
}
$sameAs = array_values(array_filter(array_map('trim', $cfg['sameAs'] ?? [])));
if ($sameAs !== []) {
$org['sameAs'] = $sameAs;
}
$contact = [];
if (!empty($cfg['phone'])) {
$contact['telephone'] = (string)$cfg['phone'];
}
if (!empty($cfg['email'])) {
$contact['email'] = (string)$cfg['email'];
}
if ($contact !== []) {
$org['contactPoint'] = array_merge([
'@type' => 'ContactPoint',
'contactType' => 'customer support',
], $contact);
}
$address = array_filter([
'streetAddress' => (string)($cfg['street'] ?? ''),
'postalCode' => (string)($cfg['postalCode'] ?? ''),
'addressLocality' => (string)($cfg['locality'] ?? ''),
'addressCountry' => (string)($cfg['country'] ?? ''),
], static fn ($v) => $v !== '');
if ($address !== []) {
$org['address'] = array_merge(['@type' => 'PostalAddress'], $address);
}
return $org;
}
/**
* schema.org/WebSite — emitted on the home page. Links to Organization as
* publisher.
*
* @return array<string,mixed>
*/
public function buildWebSite(string $base, string $name, bool $hasOrganization): array
{
$base = $this->normalizeBase($base);
$site = [
'@type' => 'WebSite',
'@id' => $base . self::WEBSITE_FRAGMENT,
'url' => $base . '/',
'name' => $name,
];
if ($hasOrganization) {
$site['publisher'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
}
return $site;
}
/**
* schema.org/BreadcrumbList from an ordered rootline.
*
* @param list<array{name:string, url:string}> $items absolute or root-relative urls
* @return array<string,mixed>|null null when fewer than 2 levels (no useful breadcrumb)
*/
public function buildBreadcrumbList(array $items, string $base): ?array
{
$base = $this->normalizeBase($base);
$listElements = [];
$position = 1;
foreach ($items as $item) {
$name = trim((string)($item['name'] ?? ''));
if ($name === '') {
continue;
}
$element = [
'@type' => 'ListItem',
'position' => $position,
'name' => $name,
];
$url = (string)($item['url'] ?? '');
if ($url !== '') {
$element['item'] = $this->absUrl($url, $base);
}
$listElements[] = $element;
$position++;
}
if (count($listElements) < 2) {
return null;
}
return [
'@type' => 'BreadcrumbList',
'itemListElement' => $listElements,
];
}
/**
* schema.org/NewsArticle for a news detail page.
*
* @param array<string,mixed> $page pages record (title, author, crdate, …)
* @param string[] $images absolute image urls (may be empty)
* @return array<string,mixed>
*/
public function buildNewsArticle(array $page, string $base, array $images, bool $hasOrganization): array
{
$base = $this->normalizeBase($base);
$headline = trim((string)($page['seo_title'] ?? '')) ?: trim((string)($page['title'] ?? ''));
$description = trim((string)($page['description'] ?? '')) ?: trim((string)($page['abstract'] ?? ''));
$article = [
'@type' => 'NewsArticle',
'headline' => $headline,
'datePublished' => $this->isoDate((int)($page['crdate'] ?? 0)),
'dateModified' => $this->isoDate((int)($page['SYS_LASTCHANGED'] ?? $page['tstamp'] ?? 0)),
];
if (!empty($page['slug'])) {
$article['mainEntityOfPage'] = [
'@type' => 'WebPage',
'@id' => $this->absUrl((string)$page['slug'], $base),
];
}
if ($description !== '') {
$article['description'] = $description;
}
if ($images !== []) {
$article['image'] = array_values($images);
}
$author = trim((string)($page['author'] ?? ''));
if ($author !== '') {
$person = ['@type' => 'Person', 'name' => $author];
if (!empty($page['author_email'])) {
$person['email'] = (string)$page['author_email'];
}
$article['author'] = $person;
}
if ($hasOrganization) {
$article['publisher'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
}
return $article;
}
/**
* schema.org/Product. VITEC products are B2B AV/broadcast tech without
* public pricing, so no `offers` node is emitted (would be invalid empty).
*
* @param array<string,mixed> $p serialized product (title, description, images, …)
* @param string[] $images absolute image urls
* @return array<string,mixed>
*/
public function buildProduct(array $p, string $base, array $images, bool $hasOrganization): array
{
$base = $this->normalizeBase($base);
$name = trim((string)($p['title'] ?? ''));
$description = $this->plainText((string)($p['teaser'] ?? '')) ?: $this->plainText((string)($p['description'] ?? ''));
$product = [
'@type' => 'Product',
'name' => $name,
];
if ($description !== '') {
$product['description'] = $description;
}
if ($images !== []) {
$product['image'] = array_values($images);
}
if (!empty($p['slug'])) {
$product['url'] = $this->absUrl('/product/' . (string)$p['slug'], $base);
}
if (!empty($p['uid'])) {
$product['sku'] = 'VITEC-' . (int)$p['uid'];
}
// First category as schema.org category.
$categories = $p['categories'] ?? [];
if (is_array($categories) && isset($categories[0]['title'])) {
$product['category'] = (string)$categories[0]['title'];
}
$product['brand'] = ['@type' => 'Brand', 'name' => 'VITEC'];
if ($hasOrganization) {
$product['manufacturer'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
}
return $product;
}
/**
* schema.org/VideoObject from a product's video. Supports both an external
* embed URL (`video`) and an uploaded FAL file (`videofile`). Returns null
* when there is too little data for valid markup (Google requires at least
* name + thumbnailUrl + uploadDate).
*
* @param array<string,mixed> $p serialized product
* @param string[] $thumbnails absolute image urls (used as thumbnailUrl)
* @param int $uploadTs unix timestamp for uploadDate
* @return array<string,mixed>|null
*/
public function buildVideoObject(array $p, string $base, array $thumbnails, int $uploadTs): ?array
{
$base = $this->normalizeBase($base);
$embedUrl = trim((string)($p['video'] ?? ''));
$fileUrl = '';
if (is_array($p['videofile'] ?? null) && !empty($p['videofile']['url'])) {
$fileUrl = $this->absUrl((string)$p['videofile']['url'], $base);
}
if ($embedUrl === '' && $fileUrl === '') {
return null;
}
if ($thumbnails === []) {
// Without a thumbnail Google rejects the VideoObject; skip rather
// than emit invalid markup.
return null;
}
$name = trim((string)($p['title'] ?? ''));
$description = $this->plainText((string)($p['teaser'] ?? '')) ?: $name;
$video = [
'@type' => 'VideoObject',
'name' => $name !== '' ? $name : 'Video',
'description' => $description !== '' ? $description : $name,
'thumbnailUrl' => array_values($thumbnails),
'uploadDate' => $this->isoDate($uploadTs),
];
if ($embedUrl !== '') {
$video['embedUrl'] = $embedUrl;
}
if ($fileUrl !== '') {
$video['contentUrl'] = $fileUrl;
}
return $video;
}
/**
* schema.org FAQPage from a list of question/answer pairs.
* Answers may contain RTE HTML — tags are stripped for the JSON-LD text.
*
* @param list<array{question:string,answer:string}> $items
*/
public function buildFaqPage(array $items, string $base): ?array
{
$mainEntity = [];
foreach ($items as $item) {
$question = trim((string)($item['question'] ?? ''));
$answer = trim(strip_tags((string)($item['answer'] ?? '')));
if ($question === '' || $answer === '') {
continue;
}
$mainEntity[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $answer,
],
];
}
if ($mainEntity === []) {
return null;
}
return [
'@type' => 'FAQPage',
'mainEntity' => $mainEntity,
];
}
/**
* schema.org ExhibitionEvent (trade-show) node from a
* tx_vitec_domain_model_event row.
*
* Google rich-result requirements: name, startDate, location.
* Recommended extras handled here: endDate, description, image, url,
* eventStatus, eventAttendanceMode, organizer.
*
* @param array<string,mixed> $e Event DB row
* @param list<string> $imageUrls Absolute or root-relative logo URLs
*/
public function buildEvent(array $e, string $base, array $imageUrls, bool $hasOrganization): ?array
{
$name = trim((string)($e['title'] ?? ''));
$start = (int)($e['eventstart'] ?? 0);
if ($name === '' || $start === 0) {
return null;
}
$event = [
'@type' => 'ExhibitionEvent',
'name' => $name,
'startDate' => date('Y-m-d', $start),
];
$end = (int)($e['eventend'] ?? 0);
if ($end > 0) {
$event['endDate'] = date('Y-m-d', $end);
}
$description = trim(strip_tags((string)($e['description'] ?? '')));
if ($description === '') {
$description = trim((string)($e['teaser'] ?? ''));
}
if ($description !== '') {
$event['description'] = $description;
}
// Location: Place with venue + city/country, or VirtualLocation for online.
$mode = (string)($e['attendancemode'] ?? 'offline');
$venue = trim((string)($e['venue'] ?? ''));
$city = trim((string)($e['city'] ?? ''));
$country = trim((string)($e['country'] ?? ''));
if ($mode === 'online') {
$url = trim((string)($e['eventurl'] ?? ''));
if ($url !== '') {
$event['location'] = [
'@type' => 'VirtualLocation',
'url' => $this->absUrl($url, $base),
];
}
} else {
$address = array_filter([
'@type' => 'PostalAddress',
'addressLocality' => $city !== '' ? $city : null,
'addressCountry' => $country !== '' ? $country : null,
]);
$place = array_filter([
'@type' => 'Place',
'name' => $venue !== '' ? $venue : ($city !== '' ? $city : null),
'address' => count($address) > 1 ? $address : null,
]);
if (count($place) > 1) {
$event['location'] = $place;
}
}
$statusMap = [
'scheduled' => 'https://schema.org/EventScheduled',
'cancelled' => 'https://schema.org/EventCancelled',
'postponed' => 'https://schema.org/EventPostponed',
'rescheduled' => 'https://schema.org/EventRescheduled',
'movedonline' => 'https://schema.org/EventMovedOnline',
];
$event['eventStatus'] = $statusMap[(string)($e['eventstatus'] ?? 'scheduled')] ?? $statusMap['scheduled'];
$modeMap = [
'offline' => 'https://schema.org/OfflineEventAttendanceMode',
'online' => 'https://schema.org/OnlineEventAttendanceMode',
'mixed' => 'https://schema.org/MixedEventAttendanceMode',
];
$event['eventAttendanceMode'] = $modeMap[$mode] ?? $modeMap['offline'];
$images = [];
foreach ($imageUrls as $imageUrl) {
$abs = $this->absUrl((string)$imageUrl, $base);
if ($abs !== '') {
$images[] = $abs;
}
}
if ($images !== []) {
$event['image'] = $images;
}
$eventUrl = trim((string)($e['eventurl'] ?? ''));
if ($eventUrl !== '' && !str_starts_with($eventUrl, 't3://')) {
$event['url'] = $this->absUrl($eventUrl, $base);
}
if ($hasOrganization) {
$event['organizer'] = ['@id' => $base . '/#organization'];
}
return $event;
}
/**
* Wrap one or more schema nodes into a single @graph document string.
*
* @param list<array<string,mixed>|null> $nodes
*/
public function encodeGraph(array $nodes): string
{
$nodes = array_values(array_filter($nodes));
if ($nodes === []) {
return '';
}
$document = [
'@context' => 'https://schema.org',
'@graph' => $nodes,
];
return (string)json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Make a possibly root-relative URL absolute against the canonical base.
*/
public function absUrl(string $url, string $base): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (preg_match('#^https?://#i', $url) === 1) {
return $url;
}
return $this->normalizeBase($base) . '/' . ltrim($url, '/');
}
/**
* Strip a trailing slash and whitespace from the canonical base.
*/
private function normalizeBase(string $base): string
{
$base = rtrim(trim($base), '/');
return $base;
}
private function isoDate(int $timestamp): string
{
if ($timestamp <= 0) {
return '';
}
return date('c', $timestamp);
}
/**
* Collapse HTML/whitespace to a plain single-line string for schema text
* fields (descriptions must not contain markup).
*/
private function plainText(string $value): string
{
$value = strip_tags($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
}