DEV CommiDEV Committ

This commit is contained in:
khaccount committed 2026-07-01 10:34:48 +02:00
1 parent 4541c58fe7
commit c6759186cb
145 files changed
+2742 -579

No files matched your search

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Backend\FormEngine;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
/**
* FormEngine fieldWizard rendered below the product `structureddata` field.
* Adds a "generate" button that, on click, asks the backend AJAX endpoint to
* build the JSON-LD for the current product and writes it into the field.
*
* Registered as nodeName "structuredDataGenerator" (see ext_localconf.php) and
* attached via TCA fieldWizard on tx_vitec_domain_model_product.structureddata.
*
* The button carries data-vitec-sd-generate (so the self-initializing JS module
* binds via event delegation) and data-field-name = the textarea's form name
* (itemFormElName — the reliable field reference; itemFormElID is empty in this
* context). The JS resolves the textarea via [name="…"] with a DOM fallback.
*
* The actual generation lives server-side in
* {@see \Evomedien\Vitec\Controller\Backend\StructuredDataController}.
*/
final class StructuredDataGeneratorWizard extends AbstractNode
{
public function render(): array
{
$result = $this->initializeResultArray();
$row = $this->data['databaseRow'] ?? [];
$rawUid = $row['uid'] ?? null;
$uid = (int)$rawUid;
$isNew = !is_numeric($rawUid) || $uid <= 0;
$itemName = (string)($this->data['parameterArray']['itemFormElName'] ?? '');
$buttonId = 'vitec-sd-generate-' . md5($itemName);
$hint = $isNew
? '<span class="text-warning">Bitte das Produkt zuerst speichern, dann steht die Generierung zur Verfügung.</span>'
: 'Erzeugt das JSON-LD aus den gespeicherten Produktdaten und überschreibt den Feldinhalt.';
$html = [];
$html[] = '<div class="vitec-sd-generator" style="margin-top:.5rem">';
$html[] = sprintf(
'<button type="button" class="btn btn-default btn-sm" id="%s" data-vitec-sd-generate="1" data-uid="%d" data-field-name="%s"%s>',
htmlspecialchars($buttonId),
$uid,
htmlspecialchars($itemName),
$isNew ? ' disabled' : ''
);
$html[] = 'Structured Data generieren';
$html[] = '</button>';
$html[] = '<div class="form-text">' . $hint . '</div>';
$html[] = '</div>';
$result['html'] = implode("\n", $html);
$result['javaScriptModules'][] = JavaScriptModuleInstruction::create(
'@evomedien/vitec/structured-data-generator.js'
);
return $result;
}
}
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\StructuredDataService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Doctrine\DBAL\ParameterType;
/**
* Backend AJAX endpoint: generate schema.org JSON-LD for a single product and
* return it as a string, ready to be written into the product's
* `structureddata` field by the FormEngine wizard button.
*
* Route: vitec_structureddata_generate (see Configuration/Backend/AjaxRoutes.php)
*
* Generation is based on the *saved* product record (by uid); a new/unsaved
* record returns success=false with a hint to save first. The structured data
* is built via {@see StructuredDataService} and emitted self-contained (no
* cross-document @id references), since the field content may be reused
* standalone.
*/
#[AsController]
final class StructuredDataController
{
public function __construct(
private readonly StructuredDataService $structuredDataService,
) {}
public function generate(ServerRequestInterface $request): ResponseInterface
{
$uid = (int)($request->getQueryParams()['uid'] ?? 0);
if ($uid <= 0) {
return new JsonResponse([
'success' => false,
'message' => 'Bitte das Produkt zuerst speichern, dann erneut generieren.',
]);
}
$product = $this->loadProduct($uid);
if ($product === null) {
return new JsonResponse([
'success' => false,
'message' => 'Produkt nicht gefunden.',
]);
}
$base = $this->resolveCanonicalBase((int)($product['pid'] ?? 0), $request);
$data = [
'uid' => $uid,
'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
'teaser' => (string)($product['teaser'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'categories' => $this->categories($uid),
'videofile' => $this->videoFile($uid, $base),
];
$images = $this->imageUrls($uid, $base);
// Self-contained snippet → no @id reference to the page-level Organization.
$productNode = $this->structuredDataService->buildProduct($data, $base, $images, false);
$videoNode = $this->structuredDataService->buildVideoObject(
$data,
$base,
$images,
(int)($product['crdate'] ?? $product['tstamp'] ?? 0)
);
$nodes = array_values(array_filter([$productNode, $videoNode]));
// One node → standalone object with @context; multiple → @graph.
if (count($nodes) === 1) {
$document = array_merge(['@context' => 'https://schema.org'], $nodes[0]);
} else {
$document = ['@context' => 'https://schema.org', '@graph' => $nodes];
}
$json = (string)json_encode(
$document,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return new JsonResponse([
'success' => true,
'jsonLd' => $json,
]);
}
/**
* @return array<string,mixed>|null
*/
private function loadProduct(int $uid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $queryBuilder
->select('*')
->from('tx_vitec_domain_model_product')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchAssociative();
return $row ?: null;
}
/**
* @return list<array{title:string}>
*/
private function categories(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($r) => ['title' => (string)($r['title'] ?? '')], $rows);
}
/**
* Absolute URLs of the product images (original file public URLs).
* Falls back to the OG image when no product images exist.
*
* @return string[]
*/
private function imageUrls(int $productUid, string $base): array
{
$urls = $this->publicUrlsForField($productUid, 'productimage', $base);
if ($urls === []) {
$urls = $this->publicUrlsForField($productUid, 'ogimage', $base);
}
return $urls;
}
/**
* @return string[]
*/
private function publicUrlsForField(int $productUid, string $fieldName, string $base): array
{
$rows = $this->fileReferences($productUid, $fieldName);
if ($rows === []) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$urls = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
if ($publicUrl !== '') {
$urls[] = $this->structuredDataService->absUrl($publicUrl, $base);
}
} catch (\Throwable $e) {
continue;
}
}
return $urls;
}
/**
* @return array{url:string}|null
*/
private function videoFile(int $productUid, string $base): ?array
{
$rows = $this->fileReferences($productUid, 'videofile');
if ($rows === []) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$rows[0]['uid']);
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
if ($publicUrl === '') {
return null;
}
return ['url' => $this->structuredDataService->absUrl($publicUrl, $base)];
} catch (\Throwable $e) {
return null;
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function fileReferences(int $productUid, string $fieldName): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
return $queryBuilder
->select('uid')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
private function resolveCanonicalBase(int $pid, ServerRequestInterface $request): string
{
if ($pid > 0) {
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pid);
$configured = trim((string)($site->getSettings()->get('seo.site.canonicalBase') ?? ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
} catch (\Throwable $e) {
// fall through to request host
}
}
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
}
+370
View File
@@ -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);
}
}
@@ -217,6 +217,20 @@ class DownloadcardJsonRenderer
return null;
}
$thumbnailUrl = null;
try {
$fileObj = GeneralUtility::makeInstance(TYPO3CMSCoreResourceResourceFactory::class)
->getFileObject((int)$row["file_uid"]);
$processed = $fileObj->process(
TYPO3CMSCoreResourceProcessedFile::CONTEXT_IMAGEPREVIEW,
["width" => 400, "height" => 566]
);
$thumbnailUrl = $processed->getPublicUrl();
} catch (Throwable $e) {
// ignore — keep null
}
return [
'uid' => (int)$row['uid'],
'title' => (string)($row['title'] ?? ''),
@@ -284,11 +298,26 @@ class DownloadcardJsonRenderer
return null;
}
$thumbnailUrl = null;
try {
$fileObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\ResourceFactory::class)
->getFileObject((int)$row['file_uid']);
$processed = $fileObj->process(
\TYPO3\CMS\Core\Resource\ProcessedFile::CONTEXT_IMAGEPREVIEW,
['width' => 400, 'height' => 566]
);
$thumbnailUrl = $processed->getPublicUrl();
} catch (\Throwable $e) {
// ignore — keep null
}
return [
'uid' => (int)$row['file_uid'],
'name' => (string)($row['name'] ?? ''),
'url' => '/fileadmin' . ($row['identifier'] ?? ''),
'size' => (int)($row['size'] ?? 0),
'thumbnail' => $thumbnailUrl,
'size' => (int)($row['size'] ?? 0),
'extension' => (string)($row['extension'] ?? ''),
'mimeType' => (string)($row['mime_type'] ?? ''),
'title' => (string)($row['title'] ?? ''),
@@ -212,6 +212,7 @@ class DownloadcardcollectionJsonRenderer
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'thumbnail' => $thumbnailUrl,
'width' => $width,
'descriptor' => $width . 'w',
];
@@ -225,6 +226,7 @@ class DownloadcardcollectionJsonRenderer
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'thumbnail' => $thumbnailUrl,
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
@@ -297,10 +299,25 @@ class DownloadcardcollectionJsonRenderer
return null;
}
$thumbnailUrl = null;
try {
$fileObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\ResourceFactory::class)
->getFileObject((int)$row['file_uid']);
$processed = $fileObj->process(
\TYPO3\CMS\Core\Resource\ProcessedFile::CONTEXT_IMAGEPREVIEW,
['width' => 400, 'height' => 566]
);
$thumbnailUrl = $processed->getPublicUrl();
} catch (\Throwable $e) {
// ignore — keep null
}
return [
'uid' => (int)$row['file_uid'],
'name' => (string)($row['name'] ?? ''),
'url' => '/fileadmin' . ($row['identifier'] ?? ''),
'thumbnail' => $thumbnailUrl,
'size' => (int)($row['size'] ?? 0),
'extension' => (string)($row['extension'] ?? ''),
'mimeType' => (string)($row['mime_type'] ?? ''),
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\StructuredDataService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Doctrine\DBAL\ParameterType;
/**
* Emits the page-level schema.org @graph (Organization, WebSite,
* BreadcrumbList, NewsArticle) as a JSON-LD string for the headless page
* object. Wired in setup.typoscript as:
*
* page.10.fields.jsonLd = USER
* page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
*
* The JS frontend wraps the returned string in
* `<script type="application/ld+json">…</script>` inside the page <head>.
*
* Organization data is pulled from site settings (category "seo"); the
* canonical absolute base URL likewise (with a request-host fallback) because
* the headless site base is "/".
*
* Exception-safe: returns '' on any failure, never breaks the page JSON.
*/
final class PageJsonLdRenderer
{
/** News-detail backend layouts (see BackendLayoutDataProvider::LAYOUT_MAP). */
private const NEWS_DETAIL_LAYOUTS = [13, 14, 15];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null) {
return '';
}
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo === null) {
return '';
}
$pageId = (int)$pageInfo->getId();
$pageRecord = $pageInfo->getPageRecord();
$rootLine = $pageInfo->getRootLine();
$site = $request->getAttribute('site');
$settings = $site?->getSettings();
$base = $this->resolveCanonicalBase($request, $settings);
$service = GeneralUtility::makeInstance(StructuredDataService::class);
$orgCfg = $this->organizationConfig($settings, $base);
$hasOrganization = ($orgCfg['name'] ?? '') !== '';
$nodes = [];
// Organization on every page (anchors @id references).
if ($hasOrganization) {
$nodes[] = $service->buildOrganization($orgCfg);
}
// WebSite only on the site root / home page.
$isHome = $site !== null && $pageId === (int)$site->getRootPageId();
if ($isHome) {
$siteName = (string)($settings?->get('seo.site.name') ?? '');
if ($siteName === '') {
$siteName = (string)($orgCfg['name'] ?? '');
}
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
}
// BreadcrumbList from the rootline.
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base);
if ($breadcrumb !== null) {
$nodes[] = $breadcrumb;
}
// NewsArticle on news-detail pages.
if (in_array((int)($pageRecord['layout'] ?? 0), self::NEWS_DETAIL_LAYOUTS, true)) {
$nodes[] = $service->buildNewsArticle(
$pageRecord,
$base,
$this->pageMediaUrls($pageId, $base, $service),
$hasOrganization
);
}
return $service->encodeGraph($nodes);
} catch (\Throwable $e) {
return '';
}
}
/**
* Canonical absolute base, e.g. "https://www.vitec.com".
* Prefers the explicit site setting, falls back to the request host.
*/
private function resolveCanonicalBase($request, $settings): string
{
$configured = trim((string)($settings?->get('seo.site.canonicalBase') ?? ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
/**
* Assemble the Organization config from site settings.
*
* @return array<string,mixed>
*/
private function organizationConfig($settings, string $base): array
{
$get = static fn (string $key): string => trim((string)($settings?->get($key) ?? ''));
$sameAsRaw = $get('seo.organization.sameAs');
$sameAs = $sameAsRaw !== ''
? array_filter(array_map('trim', preg_split('/[\r\n,]+/', $sameAsRaw) ?: []))
: [];
return [
'name' => $get('seo.organization.name'),
'legalName' => $get('seo.organization.legalName'),
'base' => $base,
'logoUrl' => $get('seo.organization.logoUrl'),
'sameAs' => array_values($sameAs),
'email' => $get('seo.organization.email'),
'phone' => $get('seo.organization.phone'),
'street' => $get('seo.organization.street'),
'postalCode' => $get('seo.organization.postalCode'),
'locality' => $get('seo.organization.locality'),
'country' => $get('seo.organization.country'),
];
}
/**
* Build ordered breadcrumb items (root → current) from the rootline.
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
*
* @param array<int,array<string,mixed>> $rootLine
* @return list<array{name:string, url:string}>
*/
private function breadcrumbItems(array $rootLine): array
{
$skipDoktypes = [199, 254, 255];
$items = [];
// Rootline is ordered current → root; reverse for breadcrumb order.
foreach (array_reverse($rootLine) as $page) {
$doktype = (int)($page['doktype'] ?? 1);
if (in_array($doktype, $skipDoktypes, true)) {
continue;
}
// Matches the visible headless breadcrumb (HMENU special=rootline),
// which keeps nav_hide pages for path integrity.
$name = trim((string)($page['nav_title'] ?? '')) ?: trim((string)($page['title'] ?? ''));
if ($name === '') {
continue;
}
$items[] = [
'name' => $name,
'url' => (string)($page['slug'] ?? ''),
];
}
return $items;
}
/**
* Absolute URLs of the page's `media` images (used as NewsArticle image).
*
* @return string[]
*/
private function pageMediaUrls(int $pageId, string $base, StructuredDataService $service): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$rows = $queryBuilder
->select('uid')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('pages', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('media', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ($rows === []) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$urls = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1200]);
$urls[] = $service->absUrl($imageService->getImageUri($processed), $base);
} catch (\Throwable $e) {
continue;
}
}
return $urls;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\StructuredDataService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
@@ -12,7 +13,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Imaging\ImageService;
use TYPO3\CMS\Extbase\Service\ImageService;
use Doctrine\DBAL\ParameterType;
/**
@@ -176,7 +177,7 @@ class ProductShowJsonRenderer
{
$uid = (int)$product['uid'];
return [
$data = [
'uid' => $uid,
'title' => (string)($product['title'] ?? ''),
@@ -215,6 +216,79 @@ class ProductShowJsonRenderer
'videofile' => $this->getProductVideoFile($uid),
'relatedprodukt' => $this->getRelatedProducts($uid),
];
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
// @graph string. The frontend injects it as <script type="application/ld+json">.
// Supersedes the manual `structureddata` field above (kept for backwards
// compatibility until the frontend has switched over).
$data['jsonLd'] = $this->buildJsonLd($product, $data);
return $data;
}
/**
* Build the product's structured-data @graph from the already-serialized
* product array. Exception-safe: returns '' on any failure.
*
* @param array<string,mixed> $rawProduct raw DB row (for crdate/tstamp)
* @param array<string,mixed> $data serialized product
*/
protected function buildJsonLd(array $rawProduct, array $data): string
{
try {
$service = GeneralUtility::makeInstance(StructuredDataService::class);
$base = $this->resolveCanonicalBase();
$imageUrls = [];
foreach (($data['images'] ?? []) as $img) {
if (!empty($img['url'])) {
$imageUrls[] = $service->absUrl((string)$img['url'], $base);
}
}
if ($imageUrls === [] && !empty($data['ogimage']['url'])) {
$imageUrls[] = $service->absUrl((string)$data['ogimage']['url'], $base);
}
$hasOrganization = trim((string)($this->siteSettings()?->get('seo.organization.name') ?? '')) !== '';
$product = $service->buildProduct($data, $base, $imageUrls, $hasOrganization);
$uploadTs = (int)($rawProduct['crdate'] ?? $rawProduct['tstamp'] ?? 0);
$video = $service->buildVideoObject($data, $base, $imageUrls, $uploadTs);
return $service->encodeGraph([$product, $video]);
} catch (\Throwable $e) {
return '';
}
}
/**
* Site settings of the current request, or null outside a site context.
*/
protected function siteSettings(): ?\TYPO3\CMS\Core\Site\Entity\SiteSettings
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
return $request?->getAttribute('site')?->getSettings();
}
/**
* Canonical absolute base (e.g. "https://www.vitec.com"). Prefers the
* explicit site setting, falls back to the request host.
*/
protected function resolveCanonicalBase(): string
{
$configured = trim((string)($this->siteSettings()?->get('seo.site.canonicalBase') ?? ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$normalizedParams = $request?->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
protected function getProductImages(int $productUid): array