Files
VITEC-website/packages/vitec/Classes/Service/StructuredDataService.php
2026-07-01 10:34:48 +02:00

371 lines
12 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;
}
/**
* 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);
}
}