` 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; } /** * 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); } }