371 lines
12 KiB
PHP
Executable File
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);
|
|
}
|
|
}
|