` payload that * the JS frontend injects into the page . * * 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 */ 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 */ 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 $items absolute or root-relative urls * @return array|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 $page pages record (title, author, crdate, …) * @param string[] $images absolute image urls (may be empty) * @return array */ 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 $p serialized product (title, description, images, …) * @param string[] $images absolute image urls * @return array */ 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 $p serialized product * @param string[] $thumbnails absolute image urls (used as thumbnailUrl) * @param int $uploadTs unix timestamp for uploadDate * @return array|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 $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 $e Event DB row * @param list $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|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); } }