DEV CommiDEV Committ
This commit is contained in:
1 parent
4541c58fe7
commit
c6759186cb
145 files changed
+2742
-579
No files matched your search
+370
@@ -0,0 +1,370 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user