Checkpoint: headless JSON architecture documented + cleanup (Stufe 1+2)

Restore point before Stufe 3 (central resolver-service refactoring).

Includes this session's work:
- Success Story (usecase) rebuild: new fields/tabs, UsecaseSerializer,
  thin List/Show renderers, MM relations, inline content elements.
- vitec_columns content block (two-column layout with per-item content)
  incl. unified header section; special-cased JSON resolution.
- Container per-column flex (items[].config align/justify) + gap on parent.
- Unified header section across CEs/plugins/containers; header fields in JSON.
- ISO-style architecture spec: Documentation/Headless-JSON-Architecture.md.
- Cleanup: removed local scratch + verified .bak backups.
- Fixes: B-6 (undefined thumbnail variable in Downloadcardcollection image
  resolver), B-7 (unsatisfiable legacy CType OR branch in Downloadcard/Datasheets).

Rollback: git reset --hard snapshot-2026-07-09-pre-stufe3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
o-rascheandClaude Opus 4.8 committed 2026-07-09 10:01:56 +02:00
1 parent c6759186cb
commit bb7c03235d
237 files changed
+6639 -1690

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
"evomedien/vitec": "^1.0.1",
"friendsoftypo3/content-blocks": "^2.4",
"friendsoftypo3/headless": "^5.0@rc",
"georgringer/news": "^14.0",
"netzbewegung/nb-headless-content-blocks": "^0.0.23",
"typo3/cms-backend": "^14.3",
"typo3/cms-belog": "^14.3",
Generated
+513 -413
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -15,6 +15,11 @@ dependencies:
- vitec/card
- vitec/intro-paragraph
- vitec/video
- vitec/faq
- georgringer/news
- georgringer/news-sitemap
- georgringer/news-recordlinks
- vitec/columns
frontendBase: ''
headless: 1
languages:
@@ -49,4 +54,19 @@ routeEnhancers:
tableName: tx_vitec_domain_model_product
routeFieldName: slug
routeValuePrefix: ''
NewsDetail:
type: Extbase
limitToPages:
- 21
extension: News
plugin: Pi1
routes:
- routePath: '/{news-title}'
_controller: 'News::detail'
_arguments:
news-title: news
defaultController: 'News::detail'
aspects:
news-title:
type: NewsTitle
websiteTitle: ''
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# =============================================================================
# VITEC — fix backend asset permissions after a deploy via the Windows sshfs mount
# -----------------------------------------------------------------------------
# sshfs-win (WinFsp) writes newly deployed files with mode 770 (no other-read).
# The webserver runs as "other" (suexec) -> assets under Resources/Public and
# public/_assets return HTTP 403 (backend icons / CSS / JS missing).
#
# chmod OVER the Windows mount does NOT change the real server mode, so this
# has to run ON THE SERVER, via real SSH, as the site owner (vitecevo).
#
# Usage (from the live/ project root on the server):
# bash fix-asset-perms.sh
# =============================================================================
set -u
cd "$(dirname "$0")" || exit 1
echo "[perms] Project root: $(pwd)"
echo "[perms] Setting files 644 / directories 755 under Resources/Public + _assets ..."
find public/_assets packages/vitec/Resources/Public -type f -exec chmod 644 {} \; 2>/dev/null
find public/_assets packages/vitec/Resources/Public -type d -exec chmod 755 {} \; 2>/dev/null
echo "[perms] Flushing TYPO3 caches ..."
if [ -x vendor/bin/typo3 ]; then
vendor/bin/typo3 cache:flush
else
echo "[perms] (vendor/bin/typo3 not found — skip cache flush)"
fi
echo "[perms] Done."
File mode changed.
View File
File mode changed.
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Evomedien\Vitec\Domain\Repository\EventRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
/**
* This file is part of the "VITEC" Extension for TYPO3 CMS.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* (c) 2026
*/
/**
* EventController — Extbase registration target for the Eventlist plugin.
* In headless mode the JSON output is produced by EventlistJsonRenderer,
* not by this controller.
*/
class EventController extends ActionController
{
protected EventRepository $eventRepository;
public function __construct(EventRepository $eventRepository)
{
$this->eventRepository = $eventRepository;
}
public function listAction(): ResponseInterface
{
$this->view->assign('events', $this->eventRepository->findUpcoming());
return $this->htmlResponse();
}
}
File mode changed.
File mode changed.
+57 -4
View File
@@ -12,6 +12,8 @@ use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -20,9 +22,13 @@ use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
/**
* Collect container children grouped by colPos and emit a lean, transport-
* ready structure. Each child is normalised to:
* { id, type, colPos, sorting, appearance, data }
* `data` only contains non-empty content-relevant fields.
* ready structure. Each column is normalised to:
* { config: { colPos, align, justify }, contentElements: [ { id, type, … } ] }
* `align`/`justify` are the PARENT container's per-column flex settings
* (tx_vitec_col{N}_align/justify) and therefore apply to ALL children of that
* column. Each child is normalised to { id, type, colPos, sorting, appearance,
* data }, where `data` only contains non-empty content-relevant fields —
* container-level fields (gap, col flex) are never leaked to children.
*
* VITEC list-plugins nested as container children (productlist, productshow,
* usecaselist, usecaseshow) are resolved to the SAME JSON the top-level
@@ -59,6 +65,30 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'uploads_description', 'uploads_type',
];
/**
* Container-level fields — they belong on the parent container (grid gap
* and per-column flex) and must never appear in a child's `data`.
*/
private const CONTAINER_FIELDS = [
'tx_vitec_gap',
'tx_vitec_col1_align', 'tx_vitec_col1_justify',
'tx_vitec_col2_align', 'tx_vitec_col2_justify',
'tx_vitec_col3_align', 'tx_vitec_col3_justify',
'tx_vitec_col4_align', 'tx_vitec_col4_justify',
];
/**
* Per container CType: colPos value => 1-based column number. Used to pick
* the parent's tx_vitec_col{N}_align/justify for each rendered column.
*/
private const COLPOS_TO_COLUMN = [
'vitec_cols_50_50' => [211 => 1, 212 => 2],
'vitec_cols_33_66' => [251 => 1, 252 => 2],
'vitec_cols_66_33' => [241 => 1, 242 => 2],
'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3],
'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4],
];
/** Fields whose 0/empty value is still meaningful. */
private const KEEP_IF_ZERO = [
'header_layout',
@@ -80,6 +110,16 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
'news_newsdetail' => [NewsJsonRenderer::class, 'news'],
'news_newsdatemenu' => [NewsJsonRenderer::class, 'news'],
'news_categorylist' => [NewsJsonRenderer::class, 'news'],
'news_newssearchform' => [NewsJsonRenderer::class, 'news'],
'news_newssearchresult' => [NewsJsonRenderer::class, 'news'],
'news_taglist' => [NewsJsonRenderer::class, 'news'],
];
public function process(
@@ -121,10 +161,20 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
}
ksort($byColPos);
// Per-column flex settings come from the parent container record.
$parentCType = (string)($cObj->data['CType'] ?? '');
$colMap = self::COLPOS_TO_COLUMN[$parentCType] ?? [];
$items = [];
foreach ($byColPos as $colPos => $contentElements) {
$config = ['colPos' => $colPos];
$columnNo = $colMap[$colPos] ?? null;
if ($columnNo !== null) {
$config['align'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
$config['justify'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
}
$items[] = [
'config' => ['colPos' => $colPos],
'config' => $config,
'contentElements' => $contentElements,
];
}
@@ -147,6 +197,9 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
if (in_array($field, self::SYSTEM_FIELDS, true)) {
continue;
}
if (in_array($field, self::CONTAINER_FIELDS, true)) {
continue;
}
if ($this->isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
continue;
}
@@ -0,0 +1,266 @@
<?php
namespace Evomedien\Vitec\Domain\Model;
use TYPO3\CMS\Extbase\Domain\Model\Category;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
class Event extends AbstractEntity
{
/** @var string */
protected $title = '';
/** @var string */
protected $slug = '';
/** @var string */
protected $teaser = '';
/** @var string */
protected $description = '';
/** @var \DateTime|null */
protected $eventstart;
/** @var \DateTime|null */
protected $eventend;
/** @var string */
protected $venue = '';
/** @var string */
protected $booth = '';
/** @var string */
protected $city = '';
/** @var string */
protected $country = '';
/** @var string */
protected $attendancemode = 'offline';
/** @var string */
protected $eventstatus = 'scheduled';
/** @var string */
protected $eventurl = '';
/** @var string */
protected $meetinglink = '';
/**
* @var \TYPO3\CMS\Extbase\Domain\Model\FileReference|null
* @TYPO3\CMS\Extbase\Annotation\ORM\Cascade("remove")
*/
protected $image;
/** @var bool */
protected $hideonwebsite = false;
/** @var bool */
protected $hideonapp = false;
/**
* @var \TYPO3\CMS\Extbase\Persistence\ObjectStorage<\TYPO3\CMS\Extbase\Domain\Model\Category>
* @TYPO3\CMS\Extbase\Annotation\ORM\Lazy
*/
protected $categories;
public function __construct()
{
$this->categories = new ObjectStorage();
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
public function getTeaser(): string
{
return $this->teaser;
}
public function setTeaser(string $teaser): void
{
$this->teaser = $teaser;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function getEventstart(): ?\DateTime
{
return $this->eventstart;
}
public function setEventstart(?\DateTime $eventstart): void
{
$this->eventstart = $eventstart;
}
public function getEventend(): ?\DateTime
{
return $this->eventend;
}
public function setEventend(?\DateTime $eventend): void
{
$this->eventend = $eventend;
}
public function getVenue(): string
{
return $this->venue;
}
public function setVenue(string $venue): void
{
$this->venue = $venue;
}
public function getBooth(): string
{
return $this->booth;
}
public function setBooth(string $booth): void
{
$this->booth = $booth;
}
public function getCity(): string
{
return $this->city;
}
public function setCity(string $city): void
{
$this->city = $city;
}
public function getCountry(): string
{
return $this->country;
}
public function setCountry(string $country): void
{
$this->country = $country;
}
public function getAttendancemode(): string
{
return $this->attendancemode;
}
public function setAttendancemode(string $attendancemode): void
{
$this->attendancemode = $attendancemode;
}
public function getEventstatus(): string
{
return $this->eventstatus;
}
public function setEventstatus(string $eventstatus): void
{
$this->eventstatus = $eventstatus;
}
public function getEventurl(): string
{
return $this->eventurl;
}
public function setEventurl(string $eventurl): void
{
$this->eventurl = $eventurl;
}
public function getMeetinglink(): string
{
return $this->meetinglink;
}
public function setMeetinglink(string $meetinglink): void
{
$this->meetinglink = $meetinglink;
}
public function getImage(): ?FileReference
{
return $this->image;
}
public function setImage(?FileReference $image): void
{
$this->image = $image;
}
public function getHideonwebsite(): bool
{
return $this->hideonwebsite;
}
public function setHideonwebsite(bool $hideonwebsite): void
{
$this->hideonwebsite = $hideonwebsite;
}
public function getHideonapp(): bool
{
return $this->hideonapp;
}
public function setHideonapp(bool $hideonapp): void
{
$this->hideonapp = $hideonapp;
}
public function addCategory(Category $category): void
{
$this->categories->attach($category);
}
public function removeCategory(Category $categoryToRemove): void
{
$this->categories->detach($categoryToRemove);
}
public function getCategories(): ObjectStorage
{
return $this->categories;
}
public function setCategories(ObjectStorage $categories): void
{
$this->categories = $categories;
}
}
View File
File mode changed.
@@ -0,0 +1,71 @@
<?php
namespace Evomedien\Vitec\Domain\Repository;
use TYPO3\CMS\Extbase\Persistence\QueryInterface;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;
/**
* The repository for Event
*/
class EventRepository extends Repository
{
protected $defaultOrderings = [
'eventstart' => QueryInterface::ORDER_ASCENDING,
];
/**
* All upcoming (or currently running) events, soonest first.
* "Upcoming" = event end (or start, when no end is set) is today or later.
*/
public function findUpcoming(): QueryResultInterface
{
$todayMidnight = strtotime('today');
$query = $this->createQuery();
$query->getQuerySettings()->setRespectStoragePage(false);
$query->matching(
$query->logicalAnd(
$query->equals('hideonwebsite', 0),
$query->logicalOr(
$query->greaterThanOrEqual('eventend', $todayMidnight),
$query->logicalAnd(
$query->equals('eventend', 0),
$query->greaterThanOrEqual('eventstart', $todayMidnight)
)
)
)
);
return $query->execute();
}
/**
* Past events, most recent first.
*/
public function findPast(): QueryResultInterface
{
$todayMidnight = strtotime('today');
$query = $this->createQuery();
$query->getQuerySettings()->setRespectStoragePage(false);
$query->matching(
$query->logicalAnd(
$query->equals('hideonwebsite', 0),
$query->logicalOr(
$query->logicalAnd(
$query->greaterThan('eventend', 0),
$query->lessThan('eventend', $todayMidnight)
),
$query->logicalAnd(
$query->equals('eventend', 0),
$query->greaterThan('eventstart', 0),
$query->lessThan('eventstart', $todayMidnight)
)
)
)
);
$query->setOrderings(['eventstart' => QueryInterface::ORDER_DESCENDING]);
return $query->execute();
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Loads the container collapse/expand JS + CSS into the Page module.
* Pure additive DOM enhancement — see
* Resources/Public/JavaScript/container-collapse.js.
*/
#[AsEventListener(identifier: 'vitec/load-container-collapse-assets')]
final class LoadContainerCollapseAssets
{
public function __invoke(ModifyPageLayoutContentEvent $event): void
{
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
$pageRenderer->loadJavaScriptModule('@evomedien/vitec/container-collapse.js');
$pageRenderer->addCssFile(
'EXT:vitec/Resources/Public/Css/backend-container-collapse.css'
);
}
}
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+12
View File
@@ -14,6 +14,8 @@ use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -73,6 +75,16 @@ final class ContentElementResolver
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
'news_newsdetail' => [NewsJsonRenderer::class, 'news'],
'news_newsdatemenu' => [NewsJsonRenderer::class, 'news'],
'news_categorylist' => [NewsJsonRenderer::class, 'news'],
'news_newssearchform' => [NewsJsonRenderer::class, 'news'],
'news_newssearchresult' => [NewsJsonRenderer::class, 'news'],
'news_taglist' => [NewsJsonRenderer::class, 'news'],
];
/**
+142
View File
@@ -305,6 +305,148 @@ final class StructuredDataService
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<array{question:string,answer:string}> $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<string,mixed> $e Event DB row
* @param list<string> $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.
*
@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the
* headless JSON shapes consumed by the React frontend.
*
* serializeListItem() -> lean card data for the list plugin
* serializeDetail() -> full detail (hero, content elements, related, seo)
*
* Inline content elements are resolved flat; the VITEC Columns content block
* (vitec_columns) is resolved specially into { header…, layout, columns:{…} }.
*
* All relation/FAL resolution lives here so the List/Show UserFunc renderers
* stay thin. Every resolver is exception-safe and returns null/[] on failure.
*/
final class UsecaseSerializer
{
private const TABLE = 'tx_vitec_domain_model_usecase';
private const IMAGE_WIDTHS = [400, 800, 1200, 1600];
private const COLUMNS_CTYPE = 'vitec_columns';
/**
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function serializeListItem(array $u): array
{
$uid = (int)$u['uid'];
return [
'uid' => $uid,
'title' => (string)($u['title'] ?? ''),
'slug' => (string)($u['slug'] ?? ''),
'subtitle' => (string)($u['subtitle'] ?? ''),
'teaser' => (string)($u['teaser'] ?? ''),
'cardImage' => $this->image($uid, 'card_image'),
'customerLogo' => $this->image($uid, 'customer_logo'),
'market' => $this->market((int)($u['market'] ?? 0)),
'categories' => $this->categories($uid),
'featured' => (bool)($u['featured'] ?? false),
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
];
}
/**
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function serializeDetail(array $u): array
{
$uid = (int)$u['uid'];
$base = $this->serializeListItem($u);
return array_merge($base, [
'hero' => [
'bgImage' => $this->image($uid, 'hero_bgimage'),
'smallImage' => $this->image($uid, 'hero_small_image'),
'video' => $this->video($uid, 'hero_video'),
'overlayColor' => (string)($u['hero_overlay_color'] ?? ''),
'overlayOpacity' => (float)($u['hero_overlay_opacity'] ?? 0),
'layout' => (string)($u['hero_layout'] ?? 'fullscreen'),
'textTheme' => (string)($u['text_theme'] ?? 'light'),
],
'contentElements' => $this->contentElements($uid),
'related' => [
'show' => (bool)($u['show_related'] ?? true),
'market' => $base['market'],
'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid),
'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid),
'categories' => $base['categories'],
],
'seo' => $this->seo($u),
'appearance' => [
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
'accentColor' => (string)($u['accent_color'] ?? ''),
'featured' => (bool)($u['featured'] ?? false),
'heroLayout' => (string)($u['hero_layout'] ?? 'fullscreen'),
'textTheme' => (string)($u['text_theme'] ?? 'light'),
],
]);
}
// ---------------------------------------------------------------- images
/**
* FAL image (with srcset). $table defaults to the usecase table but can be
* a content-block collection table (for Columns items).
*
* @return array<string,mixed>|null
*/
public function image(int $recordUid, string $fieldName, ?string $table = null): ?array
{
try {
$table = $table ?? self::TABLE;
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('sfr.deleted', 0),
$qb->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $ref['crop'] ?? null]
);
return [
'uid' => (int)$ref['uid'],
'url' => $imageService->getImageUri($default),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''),
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Throwable $e) {
return null;
}
}
/**
* FAL video (public URL only). $table as in image().
*
* @return array<string,mixed>|null
*/
public function video(int $recordUid, string $fieldName, ?string $table = null): ?array
{
try {
$table = $table ?? self::TABLE;
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('uid', 'title', 'description')
->from('sys_file_reference')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
return [
'uid' => (int)$ref['uid'],
'url' => $fileReference->getPublicUrl(),
'title' => (string)($ref['title'] ?? ''),
'mimeType' => $fileReference->getProperty('mime_type'),
];
} catch (\Throwable $e) {
return null;
}
}
// ------------------------------------------------------------- relations
/**
* Single market (foreign_table, no MM) -> title/slug.
*
* @return array<string,mixed>|null
*/
public function market(int $marketUid): ?array
{
if ($marketUid <= 0) {
return null;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_market');
$row = $qb
->select('uid', 'title', 'subtitle', 'teaser')
->from('tx_vitec_domain_model_market')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($marketUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$row) {
return null;
}
return [
'uid' => (int)$row['uid'],
'title' => (string)($row['title'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
];
} catch (\Throwable $e) {
return null;
}
}
/**
* n:n relation resolved through an MM table -> [{uid,title}, …].
*
* @return array<int,array<string,mixed>>
*/
public function relationMulti(string $mmTable, string $foreignTable, int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($foreignTable);
$rows = $qb
->select('f.uid', 'f.title')
->from($foreignTable, 'f')
->join('f', $mmTable, 'mm', 'mm.uid_foreign = f.uid')
->where(
$qb->expr()->eq('mm.uid_local', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('f.deleted', 0),
$qb->expr()->eq('f.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($r) => [
'uid' => (int)$r['uid'],
'title' => (string)($r['title'] ?? ''),
], $rows);
} catch (\Throwable $e) {
return [];
}
}
/**
* System categories (sys_category MM) -> [{uid,title,description}, …].
*
* @return array<int,array<string,mixed>>
*/
public function categories(int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $qb
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter(self::TABLE, ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($c) => [
'uid' => (int)$c['uid'],
'title' => (string)($c['title'] ?? ''),
'description' => (string)($c['description'] ?? ''),
], $rows);
} catch (\Throwable $e) {
return [];
}
}
// ------------------------------------------------------- content elements
/**
* Inline content elements (tt_content owned via tx_vitec_usecase_content).
* Flat CEs are normalised; a VITEC Columns element is resolved specially.
*
* @return array<int,array<string,mixed>>
*/
public function contentElements(int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_vitec_usecase_content', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$out = [];
foreach ($rows as $row) {
$resolved = $this->resolveInlineElement($row);
if ($resolved !== null) {
$out[] = $resolved;
}
}
return $out;
} catch (\Throwable $e) {
return [];
}
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>|null
*/
private function resolveInlineElement(array $row): ?array
{
if ((string)($row['CType'] ?? '') === self::COLUMNS_CTYPE) {
return $this->resolveColumnsElement($row);
}
return ContentElementResolver::normaliseRecord($row);
}
/**
* VITEC Columns content block -> { header…, layout, columns:{left,right} }.
* Items live in a content-blocks collection table (discovered from TCA) and
* carry a `column` (left/right) plus a typed payload.
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function resolveColumnsElement(array $row): array
{
$uid = (int)$row['uid'];
$left = [];
$right = [];
$table = $this->collectionTable('vitec_items') ?? $this->collectionTable('items');
if ($table !== null) {
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$items = $qb
->select('*')
->from($table)
->where(
$qb->expr()->eq('foreign_table_parent_uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($items as $it) {
$column = (string)($it['vitec_column'] ?? $it['column'] ?? 'left');
$resolved = $this->resolveColumnItem($it, $table);
if ($column === 'right') {
$right[] = $resolved;
} else {
$left[] = $resolved;
}
}
} catch (\Throwable $e) {
// collection table not ready / query failed -> empty columns
}
}
return [
'id' => $uid,
'type' => self::COLUMNS_CTYPE,
'colPos' => (int)($row['colPos'] ?? 0),
'sorting' => (int)($row['sorting'] ?? 0),
'header' => (string)($row['header'] ?? ''),
'subheader' => (string)($row['subheader'] ?? ''),
'headerLayout' => (int)($row['header_layout'] ?? 0),
'headerPosition' => (string)($row['header_position'] ?? ''),
'headerLink' => (string)($row['header_link'] ?? ''),
'layout' => (string)($row['vitec_layout'] ?? $row['layout'] ?? 'cols_50_50'),
'columns' => [
'left' => $left,
'right' => $right,
],
];
}
/**
* @param array<string,mixed> $it
* @return array<string,mixed>
*/
private function resolveColumnItem(array $it, string $table): array
{
$uid = (int)$it['uid'];
$get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? '');
$imageField = array_key_exists('vitec_image', $it) ? 'vitec_image' : 'image';
$videoField = array_key_exists('vitec_video', $it) ? 'vitec_video' : 'video';
return [
'type' => $get('item_type') !== '' ? $get('item_type') : 'text',
'headline' => $get('headline'),
'text' => $get('text'),
'image' => $this->image($uid, $imageField, $table),
'video' => $this->video($uid, $videoField, $table),
'link' => $get('link'),
'linkLabel' => $get('link_label'),
];
}
/**
* Resolve a content-blocks collection field's storage table from TCA.
*/
private function collectionTable(string $field): ?string
{
$t = $GLOBALS['TCA']['tt_content']['columns'][$field]['config']['foreign_table'] ?? null;
return (is_string($t) && $t !== '') ? $t : null;
}
// ------------------------------------------------------------------- seo
/**
* SEO bundle with fallback resolution (title/teaser/card/hero images).
*
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function seo(array $u): array
{
$uid = (int)$u['uid'];
$title = $this->firstNonEmpty([(string)($u['seo_title'] ?? ''), (string)($u['title'] ?? '')]);
$desc = $this->firstNonEmpty([(string)($u['seo_description'] ?? ''), (string)($u['teaser'] ?? '')]);
$ogTitle = $this->firstNonEmpty([(string)($u['og_title'] ?? ''), $title]);
$ogDesc = $this->firstNonEmpty([(string)($u['og_description'] ?? ''), $desc]);
$ogImage = $this->image($uid, 'og_image')
?? $this->image($uid, 'card_image')
?? $this->image($uid, 'hero_bgimage');
$twTitle = $this->firstNonEmpty([(string)($u['twitter_title'] ?? ''), $ogTitle]);
$twDesc = $this->firstNonEmpty([(string)($u['twitter_description'] ?? ''), $ogDesc]);
$twImage = $this->image($uid, 'twitter_image') ?? $ogImage;
return [
'title' => $title,
'description' => $desc,
'canonical' => (string)($u['canonical_link'] ?? ''),
'robots' => [
'noIndex' => (bool)($u['no_index'] ?? false),
'noFollow' => (bool)($u['no_follow'] ?? false),
],
'openGraph' => [
'title' => $ogTitle,
'description' => $ogDesc,
'image' => $ogImage,
],
'twitter' => [
'title' => $twTitle,
'description' => $twDesc,
'image' => $twImage,
],
];
}
/**
* @param array<int,string> $candidates
*/
private function firstNonEmpty(array $candidates): string
{
foreach ($candidates as $c) {
$c = trim($c);
if ($c !== '') {
return $c;
}
}
return '';
}
}
+169 -9
View File
@@ -7,6 +7,7 @@ namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
@@ -49,13 +50,7 @@ class DatasheetsJsonRenderer
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->or(
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_datasheets', ParameterType::STRING)),
$qb->expr()->and(
$qb->expr()->eq('CType', $qb->createNamedParameter('list', ParameterType::STRING)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_datasheets', ParameterType::STRING))
)
),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
@@ -118,6 +113,7 @@ class DatasheetsJsonRenderer
$response['debug'] = [
'productCount' => count($itemsOut),
'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromItems($itemsOut),
];
}
@@ -259,6 +255,7 @@ class DatasheetsJsonRenderer
private function serializeDatasheet(array $download): array
{
$uid = (int)$download['uid'];
$filetype = $this->getDownloadFileType($uid);
return [
'uid' => $uid,
'title' => (string)($download['title'] ?? ''),
@@ -266,10 +263,48 @@ class DatasheetsJsonRenderer
'teaser' => (string)($download['teaser'] ?? ''),
'description' => (string)($download['description'] ?? ''),
'tstamp' => (int)($download['tstamp'] ?? 0),
'file' => $this->getDownloadFile($uid),
'filetype' => $filetype,
'file' => $this->getDownloadFile($uid, (string)($download['fileprefix'] ?? ''), $filetype),
];
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/**
* @return array<string,mixed>|null
*/
@@ -356,7 +391,7 @@ class DatasheetsJsonRenderer
/**
* @return array<string,mixed>|null
*/
private function getDownloadFile(int $downloadUid): ?array
private function getDownloadFile(int $downloadUid, string $fileprefix = '', string $filetype = ''): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -378,7 +413,7 @@ class DatasheetsJsonRenderer
->fetchAssociative();
if (!$row) {
return null;
return $this->resolveFileByConvention($fileprefix, $filetype);
}
return [
@@ -392,4 +427,129 @@ class DatasheetsJsonRenderer
'description' => (string)($row['description'] ?? ''),
];
}
/**
* Resolve file from naming convention:
* [fileprefix]__[filetype]__[number]-[letter].ext
* and pick highest number + highest letter.
*
* @return array<string,mixed>|null
*/
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$escapedPrefix = preg_quote($fileprefix, '/');
$escapedType = preg_quote($filetype, '/');
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '.' || $entry === '..') {
continue;
}
if (!preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letter = strtoupper($matches[2]);
$letterRank = $this->letterSequenceToRank($letter);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$fullPath = $baseDir . '/' . $bestFile;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $bestFile,
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
}
private function letterSequenceToRank(string $letters): int
{
$rank = 0;
$letters = strtoupper($letters);
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$char = ord($letters[$i]);
if ($char < 65 || $char > 90) {
continue;
}
$rank = ($rank * 26) + ($char - 64);
}
return $rank;
}
/**
* @param array<int,array<string,mixed>> $items
* @return array<int,array<string,mixed>>
*/
private function collectFallbackFilesFromItems(array $items): array
{
$fallbackFiles = [];
foreach ($items as $item) {
$datasheet = $item['datasheet'] ?? null;
if (!is_array($datasheet)) {
continue;
}
$file = $datasheet['file'] ?? null;
if (!is_array($file) || (int)($file['uid'] ?? 1) !== 0) {
continue;
}
$fallbackFiles[] = [
'datasheetUid' => (int)($datasheet['uid'] ?? 0),
'name' => (string)($file['name'] ?? ''),
'url' => (string)($file['url'] ?? ''),
'source' => 'naming-convention-fallback',
];
}
return $fallbackFiles;
}
}
+172 -14
View File
@@ -7,6 +7,7 @@ namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -44,20 +45,12 @@ class DownloadcardJsonRenderer
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
// After v14 migration the CType is the plugin signature; for legacy
// (unmigrated) records the CType is still "list" with list_type set.
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->or(
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_downloadcard', ParameterType::STRING)),
$qb->expr()->and(
$qb->expr()->eq('CType', $qb->createNamedParameter('list', ParameterType::STRING)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_downloadcard', ParameterType::STRING))
)
),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
@@ -106,14 +99,17 @@ class DownloadcardJsonRenderer
: '';
}
$serializedDownload = $this->serializeDownload($download);
$response = [
'mode' => 'single',
'download' => $this->serializeDownload($download),
'download' => $serializedDownload,
'settings' => $settingsOut,
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId, 'downloadUid' => $downloadUid, 'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromDownloads([$serializedDownload]),
];
}
return json_encode($response);
@@ -123,11 +119,12 @@ class DownloadcardJsonRenderer
if ($productUid > 0) {
$downloads = $this->fetchDownloadsForProduct($productUid);
$product = $this->fetchProductContext($productUid);
$serializedDownloads = array_map(fn($r) => $this->serializeDownload($r), $downloads);
$response = [
'mode' => 'product-downloads',
'product' => $product,
'downloads' => array_map(fn($r) => $this->serializeDownload($r), $downloads),
'downloads' => $serializedDownloads,
'settings' => $settingsOut,
];
if ($debugMode) {
@@ -136,6 +133,7 @@ class DownloadcardJsonRenderer
'productUid' => $productUid,
'downloadCount' => count($downloads),
'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromDownloads($serializedDownloads),
];
}
return json_encode($response);
@@ -248,6 +246,8 @@ class DownloadcardJsonRenderer
protected function serializeDownload(array $download): array
{
$uid = (int)$download['uid'];
$fileprefix = (string)($download['fileprefix'] ?? '');
$filetype = $this->getDownloadFileType($uid);
return [
'uid' => $uid,
@@ -258,22 +258,60 @@ class DownloadcardJsonRenderer
'keywords' => (string)($download['keywords'] ?? ''),
'icon' => (string)($download['icon'] ?? ''),
'filepath' => (string)($download['filepath'] ?? ''),
'fileprefix' => (string)($download['fileprefix'] ?? ''),
'fileprefix' => $fileprefix,
'filetype' => $filetype,
'private_download' => (bool)($download['private_download'] ?? false),
'hideonapp' => (bool)($download['hideonapp'] ?? false),
'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($download['hideondatasheets'] ?? false),
'hideonproducts' => (bool)($download['hideonproducts'] ?? false),
'file' => $this->getDownloadFile($uid),
'file' => $this->getDownloadFile($uid, $fileprefix, $filetype),
];
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/**
* Resolve the FAL file reference (fieldname=file) for a download.
*
* @return array<string,mixed>|null
*/
protected function getDownloadFile(int $downloadUid): ?array
protected function getDownloadFile(int $downloadUid, string $fileprefix = '', string $filetype = ''): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -295,7 +333,7 @@ class DownloadcardJsonRenderer
->fetchAssociative();
if (!$row) {
return null;
return $this->resolveFileByConvention($fileprefix, $filetype);
}
$thumbnailUrl = null;
@@ -324,4 +362,124 @@ class DownloadcardJsonRenderer
'description' => (string)($row['description'] ?? ''),
];
}
/**
* @return array<string,mixed>|null
*/
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$escapedPrefix = preg_quote($fileprefix, '/');
$escapedType = preg_quote($filetype, '/');
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '.' || $entry === '..') {
continue;
}
if (!preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = $this->letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$fullPath = $baseDir . '/' . $bestFile;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $bestFile,
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
'thumbnail' => null,
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
}
private function letterSequenceToRank(string $letters): int
{
$rank = 0;
$letters = strtoupper($letters);
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$char = ord($letters[$i]);
if ($char < 65 || $char > 90) {
continue;
}
$rank = ($rank * 26) + ($char - 64);
}
return $rank;
}
/**
* @param array<int,array<string,mixed>> $downloads
* @return array<int,array<string,mixed>>
*/
private function collectFallbackFilesFromDownloads(array $downloads): array
{
$fallbackFiles = [];
foreach ($downloads as $download) {
$file = $download['file'] ?? null;
if (!is_array($file)) {
continue;
}
if ((int)($file['uid'] ?? 1) !== 0) {
continue;
}
$fallbackFiles[] = [
'downloadUid' => (int)($download['uid'] ?? 0),
'name' => (string)($file['name'] ?? ''),
'url' => (string)($file['url'] ?? ''),
'source' => 'naming-convention-fallback',
];
}
return $fallbackFiles;
}
}
+168 -7
View File
@@ -7,6 +7,7 @@ namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
@@ -96,13 +97,14 @@ class DownloadcardcollectionJsonRenderer
// --- Downloads: comma-separated UID list from settings.download ---
$downloadUids = $this->parseUidList($settings['download'] ?? '');
$downloads = $downloadUids === [] ? [] : $this->fetchDownloadsByUids($downloadUids);
$serializedDownloads = array_map(fn($r) => $this->serializeDownload($r), $downloads);
// --- Header image (inline FAL on this tt_content element, fieldname=image) ---
$image = $this->getCollectionImage($ttContentUid);
$response = [
'image' => $image,
'downloads' => array_map(fn($r) => $this->serializeDownload($r), $downloads),
'downloads' => $serializedDownloads,
'settings' => $settingsOut,
];
@@ -112,6 +114,7 @@ class DownloadcardcollectionJsonRenderer
'downloadUids' => $downloadUids,
'downloadCount' => count($downloads),
'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromDownloads($serializedDownloads),
];
}
@@ -212,7 +215,6 @@ class DownloadcardcollectionJsonRenderer
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'thumbnail' => $thumbnailUrl,
'width' => $width,
'descriptor' => $width . 'w',
];
@@ -226,7 +228,6 @@ class DownloadcardcollectionJsonRenderer
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'thumbnail' => $thumbnailUrl,
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
@@ -249,6 +250,8 @@ class DownloadcardcollectionJsonRenderer
protected function serializeDownload(array $download): array
{
$uid = (int)$download['uid'];
$fileprefix = (string)($download['fileprefix'] ?? '');
$filetype = $this->getDownloadFileType($uid);
return [
'uid' => $uid,
@@ -259,22 +262,60 @@ class DownloadcardcollectionJsonRenderer
'keywords' => (string)($download['keywords'] ?? ''),
'icon' => (string)($download['icon'] ?? ''),
'filepath' => (string)($download['filepath'] ?? ''),
'fileprefix' => (string)($download['fileprefix'] ?? ''),
'fileprefix' => $fileprefix,
'filetype' => $filetype,
'private_download' => (bool)($download['private_download'] ?? false),
'hideonapp' => (bool)($download['hideonapp'] ?? false),
'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($download['hideondatasheets'] ?? false),
'hideonproducts' => (bool)($download['hideonproducts'] ?? false),
'file' => $this->getDownloadFile($uid),
'file' => $this->getDownloadFile($uid, $fileprefix, $filetype),
];
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/**
* Resolve the FAL file reference (fieldname=file) for a download.
*
* @return array<string,mixed>|null
*/
protected function getDownloadFile(int $downloadUid): ?array
protected function getDownloadFile(int $downloadUid, string $fileprefix = '', string $filetype = ''): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -296,7 +337,7 @@ class DownloadcardcollectionJsonRenderer
->fetchAssociative();
if (!$row) {
return null;
return $this->resolveFileByConvention($fileprefix, $filetype);
}
@@ -325,4 +366,124 @@ class DownloadcardcollectionJsonRenderer
'description' => (string)($row['description'] ?? ''),
];
}
/**
* @return array<string,mixed>|null
*/
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$escapedPrefix = preg_quote($fileprefix, '/');
$escapedType = preg_quote($filetype, '/');
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '.' || $entry === '..') {
continue;
}
if (!preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = $this->letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$fullPath = $baseDir . '/' . $bestFile;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $bestFile,
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
'thumbnail' => null,
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
}
private function letterSequenceToRank(string $letters): int
{
$rank = 0;
$letters = strtoupper($letters);
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$char = ord($letters[$i]);
if ($char < 65 || $char > 90) {
continue;
}
$rank = ($rank * 26) + ($char - 64);
}
return $rank;
}
/**
* @param array<int,array<string,mixed>> $downloads
* @return array<int,array<string,mixed>>
*/
private function collectFallbackFilesFromDownloads(array $downloads): array
{
$fallbackFiles = [];
foreach ($downloads as $download) {
$file = $download['file'] ?? null;
if (!is_array($file)) {
continue;
}
if ((int)($file['uid'] ?? 1) !== 0) {
continue;
}
$fallbackFiles[] = [
'downloadUid' => (int)($download['uid'] ?? 0),
'name' => (string)($file['name'] ?? ''),
'url' => (string)($file['url'] ?? ''),
'source' => 'naming-convention-fallback',
];
}
return $fallbackFiles;
}
}
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* UserFunc to render the event list (tx_vitec_domain_model_event) as JSON.
*
* Default: upcoming events (event end — or start, when no end is set — is
* today or later), soonest first. FlexForm options: showpast (append past
* events, most recent first, under "pastEvents"), limit, debug.
*
* `render()` = TypoScript entry (cObj->data or page discovery).
* `renderForRecord()` = one specific tt_content row; reused by
* ContainerChildrenProcessor / ContentElementResolver. Exception-safe.
*/
class EventlistJsonRenderer
{
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
// 1) cObj data path
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_eventlist') {
return $this->renderForRecord($row);
}
// 2) Page discovery via v14 request attribute
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_eventlist', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
return '';
}
return $this->renderForRecord($contentElements[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$showPast = (bool)($settings['showpast'] ?? false);
$daysInAdvance = (int)($settings['daysinadvance'] ?? 0);
$limit = (int)($settings['limit'] ?? 0);
$debugMode = (bool)($settings['debug'] ?? false);
$upcoming = $this->fetchEvents(true, $limit, $daysInAdvance);
$response = [
'events' => array_map(fn($e) => $this->serializeEvent($e), $upcoming),
'settings' => [
'showpast' => $showPast,
'daysinadvance' => $daysInAdvance,
'limit' => $limit,
],
];
if ($showPast) {
$past = $this->fetchEvents(false, $limit);
$response['pastEvents'] = array_map(fn($e) => $this->serializeEvent($e), $past);
}
if ($debugMode) {
$response['debug'] = [
'upcomingCount' => count($upcoming),
'settings' => $settings,
];
}
return json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function fetchEvents(bool $upcoming, int $limit, int $daysInAdvance = 0): array
{
$todayMidnight = strtotime('today');
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_event');
$expr = $qb->expr();
$qb
->select('*')
->from('tx_vitec_domain_model_event')
->where(
$expr->eq('deleted', 0),
$expr->eq('hidden', 0),
$expr->eq('hideonwebsite', 0)
);
if ($upcoming) {
// running or future: end >= today, or (no end and start >= today)
$qb->andWhere(
$expr->or(
$expr->gte('eventend', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)),
$expr->and(
$expr->eq('eventend', 0),
$expr->gte('eventstart', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER))
)
)
);
$qb->orderBy('eventstart', 'ASC');
if ($daysInAdvance > 0) {
$qb->andWhere(
$expr->lt('eventstart', $qb->createNamedParameter($todayMidnight + ($daysInAdvance * 86400), ParameterType::INTEGER))
);
}
} else {
// past: (end > 0 and end < today) or (no end and 0 < start < today)
$qb->andWhere(
$expr->or(
$expr->and(
$expr->gt('eventend', 0),
$expr->lt('eventend', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER))
),
$expr->and(
$expr->eq('eventend', 0),
$expr->gt('eventstart', 0),
$expr->lt('eventstart', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER))
)
)
);
$qb->orderBy('eventstart', 'DESC');
}
if ($limit > 0) {
$qb->setMaxResults($limit);
}
return $qb->executeQuery()->fetchAllAssociative();
}
/**
* @param array<string,mixed> $event
* @return array<string,mixed>
*/
protected function serializeEvent(array $event): array
{
$uid = (int)$event['uid'];
$start = (int)($event['eventstart'] ?? 0);
$end = (int)($event['eventend'] ?? 0);
return [
'uid' => $uid,
'title' => (string)($event['title'] ?? ''),
'slug' => (string)($event['slug'] ?? ''),
'teaser' => (string)($event['teaser'] ?? ''),
'description' => (string)($event['description'] ?? ''),
'eventstart' => $start > 0 ? date('Y-m-d', $start) : null,
'eventend' => $end > 0 ? date('Y-m-d', $end) : null,
'venue' => (string)($event['venue'] ?? ''),
'booth' => (string)($event['booth'] ?? ''),
'city' => (string)($event['city'] ?? ''),
'country' => (string)($event['country'] ?? ''),
'attendancemode' => (string)($event['attendancemode'] ?? 'offline'),
'eventstatus' => (string)($event['eventstatus'] ?? 'scheduled'),
'eventurl' => (string)($event['eventurl'] ?? ''),
'meetinglink' => (string)($event['meetinglink'] ?? ''),
'image' => $this->getEventImage($uid),
'categories' => $this->getEventCategories($uid),
];
}
/**
* @return array<string,mixed>|null
*/
protected function getEventImage(int $eventUid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $qb
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING)),
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter('image', ParameterType::STRING)),
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)),
$qb->expr()->eq('sfr.deleted', 0),
$qb->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 400, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getEventCategories(int $eventUid): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => (string)($cat['title'] ?? ''),
'description' => (string)($cat['description'] ?? ''),
];
}, $categories);
}
}
View File
File mode changed.
@@ -0,0 +1,753 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Headless JSON renderer for EXT:news content elements.
*
* The renderer supports the news list/detail plugin variants used in this
* project and emits a stable JSON structure for the frontend.
*
* Exception-safe: returns '' on any failure.
*/
final class NewsJsonRenderer
{
private const NEWS_CTYPES = [
'news_pi1',
'news_newsliststicky',
'news_newsselectedlist',
'news_newsdetail',
'news_newsdatemenu',
'news_categorylist',
'news_newssearchform',
'news_newssearchresult',
'news_taglist',
];
private const TEMPLATE_LAYOUTS = [
'1' => 'Compact List',
'2' => 'Featured Story',
'3' => 'Editorial Grid',
'4' => 'Detail Spotlight',
];
private const TEMPLATE_LAYOUT_DEFAULTS = [
'news_pi1' => '1',
'news_newsliststicky' => '1',
'news_newsselectedlist' => '1',
'news_newsdetail' => '2',
'news_newsdatemenu' => '3',
'news_categorylist' => '3',
'news_newssearchform' => '4',
'news_newssearchresult' => '4',
'news_taglist' => '3',
];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row !== null && in_array((string)($row['CType'] ?? ''), self::NEWS_CTYPES, true)) {
return $this->renderForRecord($row);
}
$pageId = $this->resolveCurrentPageId();
if ($pageId <= 0) {
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$ctypeConstraints = [];
foreach (self::NEWS_CTYPES as $ctype) {
$ctypeConstraints[] = $queryBuilder->expr()->eq(
'CType',
$queryBuilder->createNamedParameter($ctype, ParameterType::STRING)
);
}
$contentElements = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->or(...$ctypeConstraints),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ($contentElements === []) {
return '';
}
return $this->renderForRecord($contentElements[0]);
} catch (\Throwable $e) {
return '';
}
}
/**
* Render exactly the given news tt_content row.
*
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$type = (string)($contentElement['CType'] ?? '');
$mode = $type === 'news_newsdetail' ? 'detail' : 'list';
$debug = (bool)($settings['debug'] ?? false);
if ($mode === 'detail') {
$news = $this->resolveDetailNews($contentElement, $settings);
$response = [
'mode' => 'detail',
'news' => $news !== null ? $this->serializeNews($news, $settings) : null,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
if ($debug) {
$response['debug'] = [
'contentElementUid' => (int)($contentElement['uid'] ?? 0),
'settings' => $settings,
];
}
return json_encode($response) ?: '';
}
$newsRows = $this->fetchNewsList($contentElement, $settings);
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows);
$response = [
'mode' => 'list',
'items' => $items,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
if ($debug) {
$response['debug'] = [
'contentElementUid' => (int)($contentElement['uid'] ?? 0),
'count' => count($items),
'settings' => $settings,
];
}
return json_encode($response) ?: '';
} catch (\Throwable $e) {
return json_encode([
'mode' => 'error',
'message' => $e->getMessage(),
]) ?: '';
}
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>|null
*/
private function resolveDetailNews(array $contentElement, array $settings): ?array
{
$newsUid = $this->resolveNewsUid((string)($settings['singleNews'] ?? ''));
if ($newsUid <= 0) {
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$newsParam = $queryParams['tx_news_pi1']['news'] ?? null;
$newsUid = $this->resolveNewsUid((string)($newsParam ?? ''));
}
if ($newsUid > 0) {
$news = $this->fetchNewsByUid($newsUid, (int)($contentElement['sys_language_uid'] ?? 0));
if ($news !== null) {
return $news;
}
}
$list = $this->fetchNewsList($contentElement, $settings, 1);
return $list[0] ?? null;
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param int|null $limitOverride
* @return array<int,array<string,mixed>>
*/
private function fetchNewsList(array $contentElement, array $settings, ?int $limitOverride = null): array
{
$selectedNews = $this->parseUidList((string)($settings['selectedList'] ?? ''));
$storagePids = $this->parseUidList((string)($settings['startingpoint'] ?? ''));
if ($storagePids === []) {
$storagePids = [(int)($contentElement['pid'] ?? 0)];
}
$categoryUids = $this->parseUidList((string)($settings['categories'] ?? ''));
$includeSubCategories = (bool)($settings['includeSubCategories'] ?? false);
$tagUids = $this->parseUidList((string)($settings['tags'] ?? ''));
$limit = $limitOverride ?? (int)($settings['limit'] ?? 0);
$orderDirection = strtoupper((string)($settings['orderDirection'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$topNewsFirst = (bool)($settings['topNewsFirst'] ?? false);
$searchTerm = $this->resolveSearchTerm((string)($contentElement['CType'] ?? ''));
$languageUid = (int)($contentElement['sys_language_uid'] ?? 0);
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$rows = $queryBuilder
->select('n.*')
->from('tx_news_domain_model_news', 'n')
->where(
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0),
$queryBuilder->expr()->in('n.pid', $queryBuilder->createNamedParameter($storagePids, ArrayParameterType::INTEGER))
)
->orderBy('n.istopnews', $topNewsFirst ? 'DESC' : 'ASC')
->addOrderBy('n.datetime', 'DESC')
->executeQuery()
->fetchAllAssociative();
if ($languageUid > 0) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int)($row['sys_language_uid'] ?? 0) === $languageUid));
} else {
$rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)($row['sys_language_uid'] ?? 0), [0, -1], true)));
}
if ($selectedNews !== []) {
$selectedLookup = array_fill_keys($selectedNews, true);
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($selectedLookup[(int)($row['uid'] ?? 0)])));
}
if ($categoryUids !== []) {
$categoryLookup = $this->newsUidsForCategories($categoryUids, $includeSubCategories);
if ($categoryLookup === [] && !$includeSubCategories) {
$categoryLookup = $this->newsUidsForCategories($this->expandCategoryUids($categoryUids), true);
}
if ($categoryLookup !== []) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($categoryLookup[(int)($row['uid'] ?? 0)])));
}
}
if ($tagUids !== []) {
$tagLookup = $this->newsUidsForTags($tagUids);
if ($tagLookup !== []) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => isset($tagLookup[(int)($row['uid'] ?? 0)])));
}
}
if ($searchTerm !== '') {
$needle = mb_strtolower($searchTerm);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
$haystack = mb_strtolower((string)($row['title'] ?? '') . ' ' . (string)($row['teaser'] ?? '') . ' ' . (string)($row['path_segment'] ?? ''));
return str_contains($haystack, $needle);
}));
}
if ($limit > 0) {
$rows = array_slice($rows, 0, $limit);
}
return $rows;
}
/**
* @param int $newsUid
* @param int $languageUid
* @return array<string,mixed>|null
*/
private function fetchNewsByUid(int $newsUid, int $languageUid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$query = $queryBuilder
->select('n.*')
->from('tx_news_domain_model_news', 'n')
->where(
$queryBuilder->expr()->eq('n.uid', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0)
);
if ($languageUid > 0) {
$query->andWhere(
$queryBuilder->expr()->eq('n.sys_language_uid', $queryBuilder->createNamedParameter($languageUid, ParameterType::INTEGER))
);
}
$news = $query->executeQuery()->fetchAssociative();
return is_array($news) ? $news : null;
}
/**
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function serializeNews(array $news, array $settings = []): array
{
$uid = (int)($news['uid'] ?? 0);
$pathSegment = (string)($news['path_segment'] ?? '');
$urls = $this->buildNewsUrls($pathSegment, $settings);
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'pathSegment' => $pathSegment,
'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''),
'bodytext' => (string)($news['bodytext'] ?? ''),
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'author' => (string)($news['author'] ?? ''),
'authorEmail' => (string)($news['author_email'] ?? ''),
'categories' => $this->resolveCategories($uid),
'tags' => $this->resolveTags($uid),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
'related' => $this->resolveRelatedNews($uid),
'relatedLinks' => $this->resolveRelatedLinks($uid),
];
}
/**
* @param array<string,mixed> $settings
* @return array{detailUrl:string, canonicalUrl:string}
*/
private function buildNewsUrls(string $pathSegment, array $settings): array
{
$pathSegment = trim($pathSegment, '/');
if ($pathSegment === '') {
return ['detailUrl' => '', 'canonicalUrl' => ''];
}
$detailPid = (int)($settings['detailPid'] ?? 0);
if ($detailPid <= 0) {
$detailPid = $this->resolveCurrentPageId();
}
$detailPageSlug = $detailPid > 0 ? $this->resolvePageSlug($detailPid) : '';
if ($detailPageSlug === '') {
return ['detailUrl' => '', 'canonicalUrl' => ''];
}
$detailUrl = rtrim($detailPageSlug, '/') . '/' . $pathSegment;
$canonicalUrl = $this->absoluteUrl($detailUrl);
return [
'detailUrl' => $detailUrl,
'canonicalUrl' => $canonicalUrl,
];
}
private function resolvePageSlug(int $pageId): string
{
if ($pageId <= 0) {
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $queryBuilder
->select('slug')
->from('pages')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
return (string)($row['slug'] ?? '');
}
private function absoluteUrl(string $path): string
{
$path = '/' . ltrim($path, '/');
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/') . $path;
}
}
$site = $request?->getAttribute('site');
if ($site !== null) {
return rtrim((string)$site->getBase(), '/') . $path;
}
return $path;
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveCategories(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
return $queryBuilder
->select('c.uid', 'c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING))
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveTags(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_tag');
return $queryBuilder
->select('t.uid', 't.title')
->from('tx_news_domain_model_tag', 't')
->join(
't',
'tx_news_domain_model_news_tag_mm',
'mm',
'mm.uid_foreign = t.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER))
)
->orderBy('t.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @param list<int> $categoryUids
* @return array<int,true>
*/
private function newsUidsForCategories(array $categoryUids, bool $includeSubCategories): array
{
if ($categoryUids === []) {
return [];
}
$categories = $includeSubCategories ? $this->expandCategoryUids($categoryUids) : $categoryUids;
if ($categories === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category_record_mm');
$uids = $queryBuilder
->select('uid_foreign')
->from('sys_category_record_mm')
->where(
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn();
return array_fill_keys(array_map('intval', $uids), true);
}
/**
* @param list<int> $tagUids
* @return array<int,true>
*/
private function newsUidsForTags(array $tagUids): array
{
if ($tagUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news_tag_mm');
$uids = $queryBuilder
->select('uid_local')
->from('tx_news_domain_model_news_tag_mm')
->where(
$queryBuilder->expr()->in('uid_foreign', $queryBuilder->createNamedParameter($tagUids, ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn();
return array_fill_keys(array_map('intval', $uids), true);
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveRelatedNews(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
return $queryBuilder
->select('n.uid', 'n.title', 'n.path_segment')
->from('tx_news_domain_model_news', 'n')
->join(
'n',
'tx_news_domain_model_news_related_mm',
'mm',
'mm.uid_foreign = n.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('n.deleted', 0),
$queryBuilder->expr()->eq('n.hidden', 0)
)
->orderBy('n.datetime', 'DESC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveRelatedLinks(int $newsUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_link');
return $queryBuilder
->select('l.uid', 'l.title', 'l.description', 'l.uri')
->from('tx_news_domain_model_link', 'l')
->where(
$queryBuilder->expr()->eq('l.parent', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('l.deleted', 0),
$queryBuilder->expr()->eq('l.hidden', 0)
)
->orderBy('l.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
/**
* @return array<int,array<string,mixed>>
*/
private function resolveFiles(string $tableName, string $fieldName, int $uid): 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($uid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($tableName, 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();
if ($rows === []) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$files = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1400]);
$files[] = [
'uid' => (int)$row['uid'],
'url' => $imageService->getImageUri($processed),
];
} catch (\Throwable $e) {
continue;
}
}
return $files;
}
private function resolveNewsUid(string $rawValue): int
{
$rawValue = trim($rawValue);
if ($rawValue === '') {
return 0;
}
if (ctype_digit($rawValue)) {
return (int)$rawValue;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_news_domain_model_news');
$row = $queryBuilder
->select('uid')
->from('tx_news_domain_model_news')
->where(
$queryBuilder->expr()->eq('path_segment', $queryBuilder->createNamedParameter($rawValue, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
return (int)($row['uid'] ?? 0);
}
/**
* @return list<int>
*/
private function parseUidList(string $rawValue): array
{
$values = array_filter(array_map('trim', explode(',', $rawValue)));
return array_values(array_filter(array_map(static fn (string $value): int => (int)$value, $values)));
}
/**
* @param list<int> $categoryUids
* @return list<int>
*/
private function expandCategoryUids(array $categoryUids): array
{
if ($categoryUids === []) {
return [];
}
$allUids = array_fill_keys($categoryUids, true);
$queue = $categoryUids;
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
while ($queue !== []) {
$currentUid = array_shift($queue);
if ($currentUid === null) {
continue;
}
$children = $queryBuilder
->select('uid')
->from('sys_category')
->where(
$queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($currentUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchFirstColumn();
foreach ($children as $childUid) {
$childUid = (int)$childUid;
if ($childUid <= 0 || isset($allUids[$childUid])) {
continue;
}
$allUids[$childUid] = true;
$queue[] = $childUid;
}
}
return array_values(array_map('intval', array_keys($allUids)));
}
private function resolveCurrentPageId(): int
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
return (int)$pageInfo->getId();
}
}
return (int)($GLOBALS['TSFE']->id ?? 0);
}
/**
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function buildSettingsPayload(array $contentElement, array $settings): array
{
$payload = $settings;
$payload['type'] = (string)($contentElement['CType'] ?? '');
$payload['detailPid'] = (int)($settings['detailPid'] ?? 0);
$payload['listPid'] = (int)($settings['listPid'] ?? 0);
$payload['templateLayout'] = $this->resolveTemplateLayoutValue((string)($contentElement['CType'] ?? ''), (string)($settings['templateLayout'] ?? ''));
$payload['templateLayoutLabel'] = self::TEMPLATE_LAYOUTS[$payload['templateLayout']] ?? '';
$payload['templateLayouts'] = array_map(
static fn (string $label, string $key): array => ['key' => $key, 'label' => $label],
self::TEMPLATE_LAYOUTS,
array_keys(self::TEMPLATE_LAYOUTS)
);
return $payload;
}
private function resolveTemplateLayoutValue(string $type, string $value): string
{
$value = trim($value);
if ($value !== '' && isset(self::TEMPLATE_LAYOUTS[$value])) {
return $value;
}
return self::TEMPLATE_LAYOUT_DEFAULTS[$type] ?? '';
}
private function resolveSearchTerm(string $type): string
{
if ($type !== 'news_newssearchresult') {
return '';
}
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$search = $queryParams['tx_news_pi1']['search']['sword'] ?? '';
return trim((string)$search);
}
}
+185
View File
@@ -93,6 +93,24 @@ final class PageJsonLdRenderer
$hasOrganization
);
}
// FAQPage when the page contains at least one vitec_faq element.
$faqItems = $this->collectFaqItems($pageId);
if ($faqItems !== []) {
$nodes[] = $service->buildFaqPage($faqItems, $base);
}
// Event nodes when the page contains a vitec_eventlist element.
foreach ($this->collectUpcomingEvents($pageId) as $eventRow) {
$nodes[] = $service->buildEvent(
$eventRow,
$base,
$this->eventImageUrls((int)$eventRow['uid'], $base, $service),
$hasOrganization
);
}
return $service->encodeGraph($nodes);
} catch (\Throwable $e) {
@@ -225,4 +243,171 @@ final class PageJsonLdRenderer
return $urls;
}
/**
* Collect question/answer pairs from all visible vitec_faq elements on
* this page (Content Blocks collection child table).
*
* @return list<array{question:string,answer:string}>
*/
private function collectFaqItems(int $pageId): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$elements = $qb
->select('uid')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_faq', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if ($elements === []) {
return [];
}
$items = [];
foreach ($elements as $element) {
$childQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('vitec_faq_items');
$rows = $childQb
->select('*')
->from('vitec_faq_items')
->where(
$childQb->expr()->eq('foreign_table_parent_uid', $childQb->createNamedParameter((int)$element['uid'], ParameterType::INTEGER)),
$childQb->expr()->eq('deleted', 0),
$childQb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($rows as $row) {
$items[] = [
'question' => (string)($row['question'] ?? $row['vitec_question'] ?? ''),
'answer' => (string)($row['answer'] ?? $row['vitec_answer'] ?? ''),
];
}
}
return $items;
} catch (\Throwable $e) {
// Table may not exist yet (schema not updated) — skip silently.
return [];
}
}
/**
* Upcoming event rows when this page contains at least one visible
* vitec_eventlist element. Empty array otherwise.
*
* @return list<array<string,mixed>>
*/
private function collectUpcomingEvents(int $pageId): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$eventlistElement = $qb
->select('uid', 'pi_flexform')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_eventlist', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$eventlistElement) {
return [];
}
// Mirror the plugin's "days in advance" window so structured data
// matches what the page actually shows.
$daysInAdvance = 0;
$flexFormService = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Service\FlexFormService::class);
$flexSettings = $flexFormService->convertFlexFormContentToArray((string)($eventlistElement['pi_flexform'] ?? ''))['settings'] ?? [];
$daysInAdvance = (int)($flexSettings['daysinadvance'] ?? 0);
$todayMidnight = strtotime('today');
$eventQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_event');
$expr = $eventQb->expr();
$eventQb
->select('*')
->from('tx_vitec_domain_model_event')
->where(
$expr->eq('deleted', 0),
$expr->eq('hidden', 0),
$expr->eq('hideonwebsite', 0),
$expr->or(
$expr->gte('eventend', $eventQb->createNamedParameter($todayMidnight, ParameterType::INTEGER)),
$expr->and(
$expr->eq('eventend', 0),
$expr->gte('eventstart', $eventQb->createNamedParameter($todayMidnight, ParameterType::INTEGER))
)
)
)
->orderBy('eventstart', 'ASC');
if ($daysInAdvance > 0) {
$eventQb->andWhere(
$expr->lt('eventstart', $eventQb->createNamedParameter($todayMidnight + ($daysInAdvance * 86400), ParameterType::INTEGER))
);
}
return $eventQb->executeQuery()->fetchAllAssociative();
} catch (\Throwable $e) {
// Table may not exist yet — skip silently.
return [];
}
}
/**
* Root-relative logo URLs for an event (fieldname=image).
*
* @return list<string>
*/
private function eventImageUrls(int $eventUid, string $base, $service): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$rows = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('image', ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('f.missing', 0)
)
->executeQuery()
->fetchAllAssociative();
$urls = [];
foreach ($rows as $row) {
$identifier = (string)($row['identifier'] ?? '');
if ($identifier !== '') {
$urls[] = '/fileadmin' . $identifier;
}
}
return $urls;
} catch (\Throwable $e) {
return [];
}
}
}
+175 -2
View File
@@ -9,6 +9,7 @@ use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Domain\Repository\ProductRepository;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
@@ -141,6 +142,7 @@ class ProductListJsonRenderer
'categoryUids' => $categoryUids,
'productCount' => count($productsData),
'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromProducts($productsData),
],
]);
}
@@ -413,6 +415,168 @@ class ProductListJsonRenderer
}, $categories);
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/**
* @return array<string,mixed>|null
*/
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$escapedPrefix = preg_quote($fileprefix, '/');
$escapedType = preg_quote($filetype, '/');
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '.' || $entry === '..') {
continue;
}
if (!preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = $this->letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$fullPath = $baseDir . '/' . $bestFile;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $bestFile,
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
];
}
private function letterSequenceToRank(string $letters): int
{
$rank = 0;
$letters = strtoupper($letters);
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$char = ord($letters[$i]);
if ($char < 65 || $char > 90) {
continue;
}
$rank = ($rank * 26) + ($char - 64);
}
return $rank;
}
/**
* @param array<int,array<string,mixed>> $products
* @return array<int,array<string,mixed>>
*/
private function collectFallbackFilesFromProducts(array $products): array
{
$fallbackFiles = [];
foreach ($products as $product) {
$downloads = $product['downloads'] ?? null;
if (!is_array($downloads)) {
continue;
}
foreach ($downloads as $download) {
if (!is_array($download)) {
continue;
}
$file = $download['file'] ?? null;
if (!is_array($file) || (int)($file['uid'] ?? 1) !== 0) {
continue;
}
$fallbackFiles[] = [
'productUid' => (int)($product['uid'] ?? 0),
'downloadUid' => (int)($download['uid'] ?? 0),
'name' => (string)($file['name'] ?? ''),
'url' => (string)($file['url'] ?? ''),
'source' => 'naming-convention-fallback',
];
}
}
return $fallbackFiles;
}
protected function getProductDownloads(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -440,6 +604,9 @@ class ProductListJsonRenderer
$result = [];
foreach ($downloads as $download) {
$fileInfo = null;
$downloadUid = (int)$download['uid'];
$fileprefix = (string)($download['fileprefix'] ?? '');
$filetype = $this->getDownloadFileType($downloadUid);
if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -450,7 +617,7 @@ class ProductListJsonRenderer
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter((int)$download['uid'], ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
@@ -473,14 +640,20 @@ class ProductListJsonRenderer
}
}
if ($fileInfo === null) {
$fileInfo = $this->resolveFileByConvention($fileprefix, $filetype);
}
$result[] = [
'uid' => (int)$download['uid'],
'uid' => $downloadUid,
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'fileprefix' => $fileprefix,
'filetype' => $filetype,
'file' => $fileInfo,
];
}
+167 -2
View File
@@ -7,6 +7,7 @@ namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\StructuredDataService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -160,6 +161,7 @@ class ProductShowJsonRenderer
'productUid' => $productUid,
'layout' => $layout,
'settings' => $settings,
'fallbackFiles' => $this->collectFallbackFilesFromDownloads((array)($response['product']['downloads'] ?? [])),
];
}
@@ -495,6 +497,160 @@ class ProductShowJsonRenderer
}, $categories);
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/**
* @return array<string,mixed>|null
*/
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$escapedPrefix = preg_quote($fileprefix, '/');
$escapedType = preg_quote($filetype, '/');
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '.' || $entry === '..') {
continue;
}
if (!preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = $this->letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$fullPath = $baseDir . '/' . $bestFile;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $bestFile,
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
];
}
private function letterSequenceToRank(string $letters): int
{
$rank = 0;
$letters = strtoupper($letters);
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$char = ord($letters[$i]);
if ($char < 65 || $char > 90) {
continue;
}
$rank = ($rank * 26) + ($char - 64);
}
return $rank;
}
/**
* @param array<int,array<string,mixed>> $downloads
* @return array<int,array<string,mixed>>
*/
private function collectFallbackFilesFromDownloads(array $downloads): array
{
$fallbackFiles = [];
foreach ($downloads as $download) {
if (!is_array($download)) {
continue;
}
$file = $download['file'] ?? null;
if (!is_array($file) || (int)($file['uid'] ?? 1) !== 0) {
continue;
}
$fallbackFiles[] = [
'downloadUid' => (int)($download['uid'] ?? 0),
'name' => (string)($file['name'] ?? ''),
'url' => (string)($file['url'] ?? ''),
'source' => 'naming-convention-fallback',
];
}
return $fallbackFiles;
}
protected function getProductDownloads(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -522,6 +678,9 @@ class ProductShowJsonRenderer
$result = [];
foreach ($downloads as $download) {
$fileInfo = null;
$downloadUid = (int)$download['uid'];
$fileprefix = (string)($download['fileprefix'] ?? '');
$filetype = $this->getDownloadFileType($downloadUid);
if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -532,7 +691,7 @@ class ProductShowJsonRenderer
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter((int)$download['uid'], ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
@@ -555,14 +714,20 @@ class ProductShowJsonRenderer
}
}
if ($fileInfo === null) {
$fileInfo = $this->resolveFileByConvention($fileprefix, $filetype);
}
$result[] = [
'uid' => (int)$download['uid'],
'uid' => $downloadUid,
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'fileprefix' => $fileprefix,
'filetype' => $filetype,
'file' => $fileInfo,
];
}
View File
File mode changed.
+35 -170
View File
@@ -4,34 +4,33 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* UserFunc to render the usecase list as JSON for headless output.
* UserFunc: render the Success Story list as JSON (headless).
*
* `render()` = page discovery (top-level plugin). `renderForRecord()` =
* one specific tt_content row, reused by ContainerChildrenProcessor for
* usecase-list plugins nested in a b13 container. Exception-safe.
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* usecase-list plugins). All serialisation is delegated to UsecaseSerializer.
* Exception-safe.
*/
class UsecaseListJsonRenderer
{
private const TABLE = 'tx_vitec_domain_model_usecase';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
// 1) cObj data path
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_usecaselist') {
return $this->renderForRecord($row);
}
// 2) Page-id via v14 request attribute (TSFE->id is often null in JSON cObj context)
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
@@ -47,29 +46,26 @@ class UsecaseListJsonRenderer
return '';
}
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_usecaselist', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_usecaselist', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
if (empty($ces)) {
return '';
}
return $this->renderForRecord($contentElements[0]);
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
@@ -81,167 +77,36 @@ class UsecaseListJsonRenderer
$settings = $flexFormData['settings'] ?? [];
$debugMode = (bool)($settings['debug'] ?? false);
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecases = $usecaseQb
->select('u.*')
->from('tx_vitec_domain_model_usecase', 'u')
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb
->select('*')
->from(self::TABLE)
->where(
$usecaseQb->expr()->eq('u.deleted', 0),
$usecaseQb->expr()->eq('u.hidden', 0),
$usecaseQb->expr()->eq('u.hideonwebsite', 0)
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('hideonwebsite', 0)
)
->orderBy('u.title', 'ASC')
->orderBy('featured', 'DESC')
->addOrderBy('title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$usecasesData = [];
foreach ($usecases as $usecase) {
$usecasesData[] = $this->serializeUsecase($usecase);
}
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
$usecases = array_map(
static fn (array $u): array => $serializer->serializeListItem($u),
$rows
);
if ($debugMode) {
return json_encode([
'usecases' => $usecasesData,
'debug' => [
'pageId' => (int)($GLOBALS['TSFE']->id ?? 0),
'usecaseCount' => count($usecasesData),
'settings' => $settings,
],
return (string)json_encode([
'usecases' => $usecases,
'debug' => ['count' => count($usecases), 'settings' => $settings],
]);
}
return json_encode($usecasesData);
return (string)json_encode($usecases);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
{
$uid = (int)$usecase['uid'];
return [
'uid' => $uid,
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->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_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
}
+44 -185
View File
@@ -4,34 +4,32 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* UserFunc to render a single usecase as JSON for headless output.
* UserFunc: render a single Success Story as JSON (headless).
*
* `render()` = page discovery (top-level plugin). `renderForRecord()` =
* one specific tt_content row, reused by ContainerChildrenProcessor for
* usecase-show plugins nested in a b13 container. Exception-safe.
* The story is chosen from the plugin FlexForm (settings.usecase) or, when
* empty, from the request param tx_vitec_usecaseshow[usecase] (uid or slug).
* Full serialisation is delegated to UsecaseSerializer. Exception-safe.
*/
class UsecaseShowJsonRenderer
{
private const TABLE = 'tx_vitec_domain_model_usecase';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
// 1) cObj data path
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_usecaseshow') {
return $this->renderForRecord($row);
}
// 2) Page-id via v14 request attribute (TSFE->id is often null in JSON cObj context)
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
@@ -47,243 +45,104 @@ class UsecaseShowJsonRenderer
return '';
}
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_usecaseshow', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_usecaseshow', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
if (empty($ces)) {
return '';
}
return $this->renderForRecord($contentElements[0]);
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$usecaseUid = (int)($settings['usecase'] ?? 0);
$layout = (string)($settings['layout'] ?? 'default');
$debugMode = (bool)($settings['debug'] ?? false);
$usecaseUid = (int)($settings['usecase'] ?? 0);
// Fallback: uid/slug from the request (React detail route).
if (!$usecaseUid) {
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$usecaseParam = $routeParams['tx_vitec_usecaseshow']['usecase'] ?? null;
if ($usecaseParam) {
if (!is_numeric($usecaseParam)) {
$slugQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$bySlug = $slugQb
->select('uid')
->from('tx_vitec_domain_model_usecase')
->where(
$slugQb->expr()->eq('slug', $slugQb->createNamedParameter($usecaseParam)),
$slugQb->expr()->eq('deleted', 0),
$slugQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
$usecaseUid = (int)($bySlug['uid'] ?? 0);
} else {
$usecaseUid = (int)$usecaseParam;
}
$params = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$param = $params['tx_vitec_usecaseshow']['usecase'] ?? null;
if ($param !== null && $param !== '') {
$usecaseUid = is_numeric($param) ? (int)$param : $this->resolveSlug((string)$param);
}
}
if (!$usecaseUid) {
return $debugMode
? json_encode(['error' => 'No usecase selected or found', 'debug' => ['settings' => $settings]])
? (string)json_encode(['error' => 'No usecase selected or found', 'debug' => ['settings' => $settings]])
: '';
}
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecase = $usecaseQb
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$usecase = $qb
->select('*')
->from('tx_vitec_domain_model_usecase')
->from(self::TABLE)
->where(
$usecaseQb->expr()->eq('uid', $usecaseQb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$usecaseQb->expr()->eq('deleted', 0),
$usecaseQb->expr()->eq('hidden', 0)
$qb->expr()->eq('uid', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$usecase) {
return $debugMode
? json_encode(['error' => 'Usecase not found', 'debug' => ['usecaseUid' => $usecaseUid]])
? (string)json_encode(['error' => 'Usecase not found', 'debug' => ['usecaseUid' => $usecaseUid]])
: '';
}
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
$response = [
'usecase' => $this->serializeUsecase($usecase),
'usecase' => $serializer->serializeDetail($usecase),
'layout' => $layout,
'settings' => [
'layout' => $layout,
],
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'usecaseUid' => $usecaseUid,
'layout' => $layout,
'settings' => $settings,
];
$response['debug'] = ['usecaseUid' => $usecaseUid, 'layout' => $layout, 'settings' => $settings];
}
return json_encode($response);
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
private function resolveSlug(string $slug): int
{
$uid = (int)$usecase['uid'];
return [
'uid' => $uid,
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$row = $qb
->select('uid')
->from(self::TABLE)
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
$qb->expr()->eq('slug', $qb->createNamedParameter($slug)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->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_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
return (int)($row['uid'] ?? 0);
}
}
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<T3DataStructure>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Carousel Settings</sheetTitle>
<type>array</type>
<el>
<settings.slidesperview>
<label>Slides per view (desktop)</label>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>4</label>
<value>4</value>
</numIndex>
<numIndex index="1">
<label>3</label>
<value>3</value>
</numIndex>
<numIndex index="2">
<label>2</label>
<value>2</value>
</numIndex>
<numIndex index="3">
<label>1</label>
<value>1</value>
</numIndex>
</items>
<default>4</default>
</config>
</settings.slidesperview>
<settings.showarrows>
<label>Show prev/next arrows</label>
<config>
<type>check</type>
<default>1</default>
</config>
</settings.showarrows>
<settings.showindicators>
<label>Show slide indicators (dots)</label>
<config>
<type>check</type>
<default>1</default>
</config>
</settings.showindicators>
<settings.loop>
<label>Loop (wrap around at the end)</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.loop>
<settings.autoplay>
<label>Autoplay</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.autoplay>
<settings.autoplayinterval>
<label>Autoplay interval (ms)</label>
<config>
<type>number</type>
<default>5000</default>
</config>
</settings.autoplayinterval>
<settings.cssClass>
<label>Additional CSS Class</label>
<config>
<type>input</type>
<size>30</size>
<eval>trim</eval>
</config>
</settings.cssClass>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>
View File
File mode changed.
View File
File mode changed.
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Event List Settings</sheetTitle>
<type>array</type>
<el>
<settings.daysinadvance>
<label>Days in advance (0 = no limit)</label>
<description>Only show upcoming events starting within the next N days, e.g. 60.</description>
<config>
<type>number</type>
<default>0</default>
</config>
</settings.daysinadvance>
<settings.showpast>
<label>Include past events</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.showpast>
<settings.limit>
<label>Maximum number of events (0 = all)</label>
<config>
<type>number</type>
<default>0</default>
</config>
</settings.limit>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>
+4
View File
@@ -32,6 +32,10 @@ return [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-usecaselist.svg',
],
'vitec-plugin-eventlist' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-eventlist.svg',
],
'vitec-plugin-downloadcard' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-downloadcard.svg',
View File
File mode changed.
View File
File mode changed.
+42 -9
View File
@@ -20,7 +20,7 @@ config {
# =============================================================================
tt_content {
vitec_productlist < lib.contentElement
vitec_productlist < lib.contentElementWithHeader
vitec_productlist {
fields {
content {
@@ -32,7 +32,28 @@ tt_content {
}
}
vitec_productshow < lib.contentElement
news_pi1 < lib.contentElementWithHeader
news_pi1 {
fields {
content {
fields {
news = USER
news.userFunc = Evomedien\Vitec\UserFunc\NewsJsonRenderer->render
}
}
}
}
news_newsliststicky < tt_content.news_pi1
news_newsselectedlist < tt_content.news_pi1
news_newsdetail < tt_content.news_pi1
news_newsdatemenu < tt_content.news_pi1
news_categorylist < tt_content.news_pi1
news_newssearchform < tt_content.news_pi1
news_newssearchresult < tt_content.news_pi1
news_taglist < tt_content.news_pi1
vitec_productshow < lib.contentElementWithHeader
vitec_productshow {
fields {
content {
@@ -44,7 +65,7 @@ tt_content {
}
}
vitec_usecaselist < lib.contentElement
vitec_usecaselist < lib.contentElementWithHeader
vitec_usecaselist {
fields {
content {
@@ -56,7 +77,7 @@ tt_content {
}
}
vitec_usecaseshow < lib.contentElement
vitec_usecaseshow < lib.contentElementWithHeader
vitec_usecaseshow {
fields {
content {
@@ -68,7 +89,7 @@ tt_content {
}
}
vitec_marketshow < lib.contentElement
vitec_marketshow < lib.contentElementWithHeader
vitec_marketshow {
fields {
content {
@@ -80,7 +101,7 @@ tt_content {
}
}
vitec_solutionshow < lib.contentElement
vitec_solutionshow < lib.contentElementWithHeader
vitec_solutionshow {
fields {
content {
@@ -92,7 +113,7 @@ tt_content {
}
}
vitec_downloadcard < lib.contentElement
vitec_downloadcard < lib.contentElementWithHeader
vitec_downloadcard {
fields {
content {
@@ -104,7 +125,7 @@ tt_content {
}
}
vitec_downloadcardcollection < lib.contentElement
vitec_downloadcardcollection < lib.contentElementWithHeader
vitec_downloadcardcollection {
fields {
content {
@@ -116,7 +137,7 @@ tt_content {
}
}
vitec_datasheets < lib.contentElement
vitec_datasheets < lib.contentElementWithHeader
vitec_datasheets {
fields {
content {
@@ -127,6 +148,18 @@ tt_content {
}
}
}
vitec_eventlist =< lib.contentElementWithHeader
vitec_eventlist {
fields {
content {
fields {
eventlist = USER
eventlist.userFunc = Evomedien\Vitec\UserFunc\EventlistJsonRenderer->render
}
}
}
}
}
# Include container (layout) rendering definitions
View File
File mode changed.
@@ -34,6 +34,7 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
$registerPluginWithFlexForm('Downloadcard', 'Download Card', 'FILE:EXT:vitec/Configuration/FlexForms/Downloadcard.xml', 'vitec-plugin-downloadcard');
$registerPluginWithFlexForm('Downloadcardcollection', 'Download Card Collection', 'FILE:EXT:vitec/Configuration/FlexForms/Downloadcardcollection.xml', 'vitec-plugin-downloadcardcollection');
$registerPluginWithFlexForm('Datasheets', 'Datasheets', 'FILE:EXT:vitec/Configuration/FlexForms/Datasheets.xml');
$registerPluginWithFlexForm('Eventlist', 'Event List', 'FILE:EXT:vitec/Configuration/FlexForms/Eventlist.xml', 'vitec-plugin-eventlist');
// Change frame_class to allow multiple selections.
$GLOBALS['TCA']['tt_content']['columns']['frame_class']['config']['renderType'] = 'selectCheckBox';
@@ -50,4 +51,9 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
['label' => 'Vitec: Highlight Box', 'value' => 'vitec-highlight'],
]
);
// Inline parent pointer: content elements owned by a Success Story record.
$GLOBALS['TCA']['tt_content']['columns']['tx_vitec_usecase_content'] = [
'config' => ['type' => 'passthrough'],
];
})();
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
defined('TYPO3') or die();
use B13\Container\Tca\ContainerConfiguration;
use B13\Container\Tca\Registry;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
(static function (): void {
$l = 'LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:';
/** @var Registry $registry */
$registry = GeneralUtility::makeInstance(Registry::class);
$registry->configureContainer(
(new ContainerConfiguration(
'vitec_cards_carousel',
$l . 'cards_carousel.title',
$l . 'cards_carousel.description',
[
[
['name' => $l . 'cards_carousel.column', 'colPos' => 260],
],
]
))
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cards-carousel.svg')
->setGroup('vitec_custom_components')
->setSaveAndCloseInNewContentElementWizard(true)
);
$GLOBALS['TCA']['tt_content']['types']['vitec_cards_carousel']['showitem'] =
'--palette--;;general,
--palette--;;headers,
tx_vitec_bg_variant,
pi_flexform;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:cards_carousel.flexform.label,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
--palette--;;language,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
--palette--;;hidden,
--palette--;;access';
ExtensionManagementUtility::addPiFlexFormValue(
'*',
'FILE:EXT:vitec/Configuration/FlexForms/CardsCarousel.xml',
'vitec_cards_carousel'
);
$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPageTSconfig'] = ($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPageTSconfig'] ?? '') . "\n" . <<<'TSCONFIG'
mod.wizards.newContentElement.wizardItems.vitec_custom_components.elements.vitec_cards_carousel {
iconIdentifier = vitec-cards-carousel
title = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:cards_carousel.title
description = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:cards_carousel.description
tt_content_defValues {
CType = vitec_cards_carousel
}
}
TSCONFIG;
})();
@@ -35,10 +35,14 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_cols_25_25_25_25']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:palette.grid;vitec_grid,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.1;vitec_col1_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.2;vitec_col2_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.3;vitec_col3_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.4;vitec_col4_flex,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
+5 -2
View File
@@ -34,10 +34,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_cols_33_33_33']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:palette.grid;vitec_grid,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.1;vitec_col1_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.2;vitec_col2_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.3;vitec_col3_flex,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
+4 -2
View File
@@ -33,10 +33,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_cols_33_66']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:palette.grid;vitec_grid,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.sidebar_33;vitec_col1_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.main_66;vitec_col2_flex,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
+4 -2
View File
@@ -33,10 +33,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_cols_50_50']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:palette.grid;vitec_grid,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.1;vitec_col1_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.2;vitec_col2_flex,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
+4 -2
View File
@@ -33,10 +33,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_cols_66_33']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:palette.grid;vitec_grid,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.main_66;vitec_col1_flex,
--palette--;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:column.sidebar_33;vitec_col2_flex,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
+1 -2
View File
@@ -32,8 +32,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
$GLOBALS['TCA']['tt_content']['types']['vitec_container']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
--palette--;;headers,
tx_vitec_bg_variant,
pi_flexform;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.flexform.label,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
+81
View File
@@ -27,6 +27,87 @@ use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
],
]);
// -------------------------------------------------------------------------
// Shared parent-level setting: column gap scale (1–5) for the whole grid.
// Concerns the container itself (not a single column); maps to a spacing
// token in the React frontend. Rendered in the "Grid" palette on top of the
// Appearance tab, above the per-column rows.
// -------------------------------------------------------------------------
ExtensionManagementUtility::addTCAcolumns('tt_content', [
'tx_vitec_gap' => [
'label' => $l . 'gap.label',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['label' => $l . 'gap.option.1', 'value' => '1'],
['label' => $l . 'gap.option.2', 'value' => '2'],
['label' => $l . 'gap.option.3', 'value' => '3'],
['label' => $l . 'gap.option.4', 'value' => '4'],
['label' => $l . 'gap.option.5', 'value' => '5'],
],
'default' => '3',
],
],
]);
// Parent-element palette: settings that concern the whole grid, not a column.
$GLOBALS['TCA']['tt_content']['palettes']['vitec_grid'] = [
'showitem' => 'tx_vitec_gap',
];
// -------------------------------------------------------------------------
// Shared per-column flex alignment fields for the X-column containers.
// Positional: col1 = first column, col2 = second, ... (max 4 columns).
// "Align" -> CSS align-items (cross-axis alignment of the column's items)
// "Justify" -> CSS justify-content (main-axis distribution within the column)
// Reused across vitec_cols_50_50 / 33_66 / 66_33 / 33_33_33 / 25_25_25_25.
// -------------------------------------------------------------------------
$alignItems = [
['label' => $l . 'flex.align.flex_start', 'value' => 'flex-start'],
['label' => $l . 'flex.align.center', 'value' => 'center'],
['label' => $l . 'flex.align.flex_end', 'value' => 'flex-end'],
['label' => $l . 'flex.align.stretch', 'value' => 'stretch'],
];
$justifyItems = [
['label' => $l . 'flex.justify.flex_start', 'value' => 'flex-start'],
['label' => $l . 'flex.justify.center', 'value' => 'center'],
['label' => $l . 'flex.justify.space_between', 'value' => 'space-between'],
['label' => $l . 'flex.justify.space_around', 'value' => 'space-around'],
['label' => $l . 'flex.justify.space_evenly', 'value' => 'space-evenly'],
];
$columnFields = [];
for ($i = 1; $i <= 4; $i++) {
$columnFields['tx_vitec_col' . $i . '_align'] = [
'label' => $l . 'flex.align.label',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => $alignItems,
'default' => 'stretch',
],
];
$columnFields['tx_vitec_col' . $i . '_justify'] = [
'label' => $l . 'flex.justify.label',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => $justifyItems,
'default' => 'flex-start',
],
];
}
ExtensionManagementUtility::addTCAcolumns('tt_content', $columnFields);
// One palette per column: Align + Justify on a single row. The visible row
// label is overridden per container in its showitem (--palette--;<colLabel>;<palette>).
for ($i = 1; $i <= 4; $i++) {
$GLOBALS['TCA']['tt_content']['palettes']['vitec_col' . $i . '_flex'] = [
'showitem' => 'tx_vitec_col' . $i . '_align, tx_vitec_col' . $i . '_justify',
];
}
// Register VITEC wizard group header
$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPageTSconfig'] = ($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPageTSconfig'] ?? '') . "\n" . <<<'TSCONFIG'
mod.wizards.newContentElement.wizardItems.vitec {
@@ -0,0 +1,290 @@
<?php
return [
'ctrl' => [
'title' => 'VITEC Event',
'label' => 'title',
'label_alt' => 'city,eventstart',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'delete' => 'deleted',
'default_sortby' => 'eventstart ASC',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'title,teaser,venue,booth,city,country',
'iconfile' => 'EXT:vitec/Resources/Public/Icons/tx_vitec_domain_model_event.svg',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => [
'showitem' => 'title, slug, teaser, description,
--div--;Event Details, eventstart, eventend, venue, booth, city, country, attendancemode, eventstatus,
--div--;Links and Media, eventurl, meetinglink, image,
--div--;Visibility, hideonwebsite, hideonapp,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
],
],
'columns' => [
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [
'type' => 'language',
],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [
['label' => '', 'value' => 0],
],
'foreign_table' => 'tx_vitec_domain_model_event',
'foreign_table_where' => 'AND {#tx_vitec_domain_model_event}.{#pid}=###CURRENT_PID### AND {#tx_vitec_domain_model_event}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => [
'config' => [
'type' => 'passthrough',
],
],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.visible',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'items' => [
[
'label' => '',
'invertStateDisplay' => true,
],
],
],
],
'starttime' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
'config' => [
'type' => 'datetime',
'default' => 0,
],
],
'endtime' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
'config' => [
'type' => 'datetime',
'default' => 0,
],
],
'title' => [
'exclude' => false,
'label' => 'Event Name',
'config' => [
'type' => 'input',
'size' => 40,
'max' => 255,
'eval' => 'trim',
'required' => true,
],
],
'slug' => [
'exclude' => true,
'label' => 'Slug',
'config' => [
'type' => 'slug',
'size' => 50,
'generatorOptions' => [
'fields' => ['title'],
'replacements' => ['/' => '-'],
],
'fallbackCharacter' => '-',
'eval' => 'uniqueInSite',
'default' => '',
],
],
'teaser' => [
'exclude' => true,
'label' => 'Teaser',
'config' => [
'type' => 'input',
'size' => 40,
'max' => 255,
'eval' => 'trim',
],
],
'description' => [
'exclude' => true,
'label' => 'Description',
'config' => [
'type' => 'text',
'cols' => 40,
'rows' => 8,
'enableRichtext' => true,
],
],
'eventstart' => [
'exclude' => false,
'label' => 'Event Start Date',
'config' => [
'type' => 'datetime',
'format' => 'date',
'default' => 0,
'required' => true,
],
],
'eventend' => [
'exclude' => true,
'label' => 'Event End Date',
'config' => [
'type' => 'datetime',
'format' => 'date',
'default' => 0,
],
],
'venue' => [
'exclude' => true,
'label' => 'Venue',
'description' => 'e.g. "Queen Sirikit National Convention Center (QSNCC)"',
'config' => [
'type' => 'input',
'size' => 40,
'max' => 255,
'eval' => 'trim',
],
],
'booth' => [
'exclude' => true,
'label' => 'Booth / Stand',
'description' => 'e.g. "Stand H10"',
'config' => [
'type' => 'input',
'size' => 20,
'max' => 255,
'eval' => 'trim',
],
],
'city' => [
'exclude' => true,
'label' => 'City',
'config' => [
'type' => 'input',
'size' => 30,
'max' => 255,
'eval' => 'trim',
],
],
'country' => [
'exclude' => true,
'label' => 'Country',
'config' => [
'type' => 'input',
'size' => 30,
'max' => 255,
'eval' => 'trim',
],
],
'attendancemode' => [
'exclude' => true,
'label' => 'Attendance Mode',
'description' => 'Used for schema.org structured data (eventAttendanceMode).',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 'offline',
'items' => [
['label' => 'On-site (offline)', 'value' => 'offline'],
['label' => 'Online', 'value' => 'online'],
['label' => 'Hybrid (mixed)', 'value' => 'mixed'],
],
],
],
'eventstatus' => [
'exclude' => true,
'label' => 'Event Status',
'description' => 'Used for schema.org structured data (eventStatus).',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 'scheduled',
'items' => [
['label' => 'Scheduled', 'value' => 'scheduled'],
['label' => 'Cancelled', 'value' => 'cancelled'],
['label' => 'Postponed', 'value' => 'postponed'],
['label' => 'Rescheduled', 'value' => 'rescheduled'],
['label' => 'Moved Online', 'value' => 'movedonline'],
],
],
],
'eventurl' => [
'exclude' => true,
'label' => 'Event Website',
'config' => [
'type' => 'link',
'allowedTypes' => ['url', 'page'],
],
],
'meetinglink' => [
'exclude' => true,
'label' => 'Book-a-Meeting Link',
'description' => 'Link or email for the "Book a Meeting" call-to-action.',
'config' => [
'type' => 'link',
'allowedTypes' => ['url', 'email', 'page'],
],
],
'image' => [
'exclude' => true,
'label' => 'Event Logo / Image',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types',
],
],
'hideonwebsite' => [
'exclude' => true,
'label' => 'Hide on Website',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
'hideonapp' => [
'exclude' => true,
'label' => 'Hide in App',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
'categories' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.categories',
'config' => [
'type' => 'category',
],
],
],
];
View File
File mode changed.
@@ -16,26 +16,50 @@ return [
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'title,slug',
'searchFields' => 'title,slug,subtitle,teaser,seo_title,seo_description',
'iconfile' => 'EXT:vitec/Resources/Public/Icons/tx_vitec_domain_model_usecase.gif',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => ['showitem' => 'title, slug, subtitle, teaser, description, singlepid, caseimage, logoimage,
--div--;Visibility, hideonapp, hideonwebsite,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime', ],
'1' => [
'showitem' =>
'--div--;General,
title, slug, subtitle,
--div--;List view,
card_image, customer_logo, teaser,
--div--;Detail · Hero,
hero_bgimage, hero_small_image, hero_video, hero_overlay_color, hero_overlay_opacity,
--div--;Detail · Content,
content_elements,
--div--;Detail · Related,
market, solutions, products, categories,
--div--;SEO,
seo_title, seo_description, no_index, no_follow, canonical_link,
--palette--;Open Graph;ogPalette,
--palette--;Twitter;twitterPalette,
--div--;Appearance,
layout_variant, background_variant, accent_color,
featured, show_related, hero_layout, text_theme,
--palette--;Visibility;visibilityPalette,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
hidden, starttime, endtime',
],
],
'palettes' => [
'ogPalette' => ['showitem' => 'og_title, og_description, --linebreak--, og_image'],
'twitterPalette' => ['showitem' => 'twitter_title, twitter_description, --linebreak--, twitter_image'],
'visibilityPalette' => ['showitem' => 'hideonwebsite, hideonapp'],
],
'columns' => [
// ------------------------------------------------------------ system
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [
'type' => 'language',
],
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
@@ -44,275 +68,273 @@ return [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [
['', 0],
],
'foreign_table' => 'tx_vitec_domain_model_download',
'foreign_table_where' => 'AND {#tx_vitec_domain_model_download}.{#pid}=###CURRENT_PID### AND {#tx_vitec_domain_model_download}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => [
'config' => [
'type' => 'passthrough',
'items' => [['label' => '', 'value' => 0]],
'foreign_table' => 'tx_vitec_domain_model_usecase',
'foreign_table_where' => 'AND {#tx_vitec_domain_model_usecase}.{#pid}=###CURRENT_PID### AND {#tx_vitec_domain_model_usecase}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.visible',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'items' => [
[
0 => '',
1 => '',
'invertStateDisplay' => true,
],
],
'items' => [['value' => '', 'label' => '', 'invertStateDisplay' => true]],
],
],
'starttime' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
'config' => [
'type' => 'input',
'renderType' => 'inputDateTime',
'eval' => 'datetime,int',
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
],
'config' => ['type' => 'datetime', 'default' => 0, 'behaviour' => ['allowLanguageSynchronization' => true]],
],
'endtime' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
'config' => [
'type' => 'input',
'renderType' => 'inputDateTime',
'eval' => 'datetime,int',
'default' => 0,
'range' => [
'upper' => mktime(0, 0, 0, 1, 1, 2038),
],
'behaviour' => [
'allowLanguageSynchronization' => true,
],
],
'config' => ['type' => 'datetime', 'default' => 0, 'range' => ['upper' => mktime(0, 0, 0, 1, 1, 2038)], 'behaviour' => ['allowLanguageSynchronization' => true]],
],
// ----------------------------------------------------------- General
'title' => [
'exclude' => false,
'label' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:tx_vitec_domain_model_download.title',
'description' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:tx_vitec_domain_model_download.title.description',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim',
'required' => true,
'default' => '',
],
'label' => 'Title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true, 'default' => ''],
],
'slug' => [
'exclude' => false,
'label' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:tx_vitec_domain_model_download.slug',
'description' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:tx_vitec_domain_model_download.slug.description',
'label' => 'Slug',
'config' => [
'type' => 'slug',
'size' => 50,
'generatorOptions' => [
'fields' => ['title'], // TODO: adjust this field to the one you want to use
'fieldSeparator' => '-',
'replacements' => [
'/' => '',
],
],
'generatorOptions' => ['fields' => ['title'], 'fieldSeparator' => '-', 'replacements' => ['/' => '']],
'fallbackCharacter' => '-',
'eval' => 'uniqueInPid',
],
],
'singlepid' => [
'exclude' => false,
'label' => 'Page ID of Success Story',
'description' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:tx_vitec_domain_model_download.title.description',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim',
'required' => false,
'default' => '',
],
],
'teaser' => [
'exclude' => true,
'label' => 'Short Teaser (optional)',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim',
'default' => '',
],
],
'subtitle' => [
'exclude' => true,
'label' => 'Subtitle',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'default' => ''],
],
// --------------------------------------------------------- List view
'card_image' => [
'label' => 'Card Image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
'customer_logo' => [
'label' => 'Customer Logo',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
'teaser' => [
'label' => 'Teaser',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 4, 'eval' => 'trim'],
],
// ------------------------------------------------------ Detail · Hero
'hero_bgimage' => [
'label' => 'Background Image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
'hero_small_image' => [
'label' => 'Small Image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
'hero_video' => [
'label' => 'Video',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'mp4,webm,ogv,mov,m4v'],
],
'hero_overlay_color' => [
'label' => 'Overlay Color',
'config' => ['type' => 'color'],
],
'hero_overlay_opacity' => [
'label' => 'Overlay Opacity (0–1)',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim',
'default' => '',
'type' => 'number',
'format' => 'decimal',
'default' => 0,
'range' => ['lower' => 0, 'upper' => 1],
'slider' => ['step' => 0.05, 'width' => 200],
],
],
'hideonapp' => [
'exclude' => true,
'label' => 'If checked - Product will not be shown on App',
// --------------------------------------------------- Detail · Content
'content_elements' => [
'label' => 'Content Elements',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'type' => 'inline',
'foreign_table' => 'tt_content',
'foreign_field' => 'tx_vitec_usecase_content',
'foreign_sortby' => 'sorting',
'appearance' => [
'collapseAll' => true,
'expandSingle' => true,
'levelLinksPosition' => 'top',
'showSynchronizationLink' => false,
'showAllLocalizationLink' => false,
'showPossibleLocalizationRecords' => false,
'useSortable' => true,
],
'overrideChildTca' => [
'columns' => [
'colPos' => ['config' => ['default' => 999]],
],
],
],
],
// --------------------------------------------------- Detail · Related
'market' => [
'label' => 'Market',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'tx_vitec_domain_model_market',
'items' => [['label' => '', 'value' => 0]],
'default' => 0,
'maxitems' => 1,
],
],
'solutions' => [
'label' => 'Solutions',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'foreign_table' => 'tx_vitec_domain_model_solution',
'MM' => 'tx_vitec_usecase_solution_mm',
'size' => 6,
'autoSizeMax' => 20,
'maxitems' => 9999,
],
],
'products' => [
'label' => 'Products',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'foreign_table' => 'tx_vitec_domain_model_product',
'MM' => 'tx_vitec_usecase_product_mm',
'size' => 6,
'autoSizeMax' => 20,
'maxitems' => 9999,
],
],
'categories' => ['config' => ['type' => 'category']],
// --------------------------------------------------------------- SEO
'seo_title' => [
'label' => 'SEO Title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim'],
],
'seo_description' => [
'label' => 'Meta Description',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3, 'eval' => 'trim'],
],
'no_index' => [
'label' => 'No Index',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'items' => [['value' => '', 'label' => '']]],
],
'no_follow' => [
'label' => 'No Follow',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'items' => [['value' => '', 'label' => '']]],
],
'canonical_link' => [
'label' => 'Canonical URL',
'config' => ['type' => 'link'],
],
'og_title' => [
'label' => 'og:title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim'],
],
'og_description' => [
'label' => 'og:description',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3, 'eval' => 'trim'],
],
'og_image' => [
'label' => 'og:image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
'twitter_title' => [
'label' => 'twitter:title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim'],
],
'twitter_description' => [
'label' => 'twitter:description',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3, 'eval' => 'trim'],
],
'twitter_image' => [
'label' => 'twitter:image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'],
],
// -------------------------------------------------------- Appearance
'layout_variant' => [
'label' => 'Detail Layout',
'config' => [
'type' => 'select', 'renderType' => 'selectSingle', 'default' => 'standard',
'items' => [
[
0 => '',
1 => '',
'invertStateDisplay' => false,
['label' => 'Standard', 'value' => 'standard'],
['label' => 'Wide', 'value' => 'wide'],
['label' => 'Centered', 'value' => 'centered'],
['label' => 'Media-first', 'value' => 'media_first'],
],
],
],
'background_variant' => [
'label' => 'Background',
'config' => [
'type' => 'select', 'renderType' => 'selectSingle', 'default' => 'none',
'items' => [
['label' => 'None', 'value' => 'none'],
['label' => 'Orange', 'value' => 'orange'],
['label' => 'Blue', 'value' => 'blue'],
['label' => 'Graphite', 'value' => 'graphite'],
['label' => 'Midnight', 'value' => 'midnight'],
['label' => 'Light', 'value' => 'light'],
['label' => 'Dark', 'value' => 'dark'],
],
],
],
'accent_color' => [
'label' => 'Accent Color',
'config' => ['type' => 'color'],
],
'hideonwebsite' => [
'exclude' => true,
'label' => 'If checked - Product will not be shown on Website',
'label' => 'Hide on Website',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'items' => [['value' => '', 'label' => '']]],
],
'hideonapp' => [
'exclude' => true,
'label' => 'Hide on App',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'items' => [['value' => '', 'label' => '']]],
],
'featured' => [
'label' => 'Featured',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'items' => [['value' => '', 'label' => '']]],
],
'show_related' => [
'label' => 'Show Related section',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 1, 'items' => [['value' => '', 'label' => '']]],
],
'hero_layout' => [
'label' => 'Hero Layout',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'type' => 'select', 'renderType' => 'selectSingle', 'default' => 'fullscreen',
'items' => [
[
0 => '',
1 => '',
'invertStateDisplay' => false,
['label' => 'Fullscreen', 'value' => 'fullscreen'],
['label' => 'Medium', 'value' => 'md'],
['label' => 'Small', 'value' => 'sm'],
],
],
],
],
'description' => [
'exclude' => true,
'label' => 'Description',
'text_theme' => [
'label' => 'Text Theme',
'config' => [
'type' => 'text',
'enableRichtext' => 'true',
'eval' => 'trim',
'default' => '',
],
'defaultExtras' => 'richtext:rte_transform[mode=ts_css]',
],
'caseimage' => [
'exclude' => true,
'label' => 'Case Image',
'config' => [
'type' => 'inline',
'foreign_table' => 'sys_file_reference',
'foreign_field' => 'uid_foreign',
'foreign_sortby' => 'sorting_foreign',
'foreign_table_field' => 'tablenames',
'foreign_match_fields' => [
'fieldname' => 'caseimage',
],
'foreign_label' => 'uid_local',
'foreign_selector' => 'uid_local',
'overrideChildTca' => [
'columns' => [
'uid_local' => [
'config' => [
'appearance' => [
'elementBrowserType' => 'file',
'elementBrowserAllowed' => 'jpg,jpeg,png,gif',
'type' => 'select', 'renderType' => 'selectSingle', 'default' => 'light',
'items' => [
['label' => 'Light', 'value' => 'light'],
['label' => 'Dark', 'value' => 'dark'],
],
],
],
],
'types' => [
'0' => [
'showitem' => '
--palette--;;imageoverlayPalette,
--palette--;;filePalette',
],
],
],
'maxitems' => 1,
'appearance' => [
'headerThumbnail' => [
'field' => 'uid_local',
'width' => '45',
'height' => '45',
],
'enabledControls' => [
'info' => true,
'new' => false,
'dragdrop' => true,
'sort' => false,
'hide' => true,
'delete' => true,
],
'fileUploadAllowed' => true,
],
],
],
'logoimage' => [
'exclude' => true,
'label' => 'Customer Logo',
'config' => [
'type' => 'inline',
'foreign_table' => 'sys_file_reference',
'foreign_field' => 'uid_foreign',
'foreign_sortby' => 'sorting_foreign',
'foreign_table_field' => 'tablenames',
'foreign_match_fields' => [
'fieldname' => 'logoimage',
],
'foreign_label' => 'uid_local',
'foreign_selector' => 'uid_local',
'overrideChildTca' => [
'columns' => [
'uid_local' => [
'config' => [
'appearance' => [
'elementBrowserType' => 'file',
'elementBrowserAllowed' => 'jpg,jpeg,png,gif',
],
],
],
],
'types' => [
'0' => [
'showitem' => '
--palette--;;imageoverlayPalette,
--palette--;;filePalette',
],
],
],
'maxitems' => 1,
'appearance' => [
'headerThumbnail' => [
'field' => 'uid_local',
'width' => '45',
'height' => '45',
],
'enabledControls' => [
'info' => true,
'new' => false,
'dragdrop' => true,
'sort' => false,
'hide' => true,
'delete' => true,
],
'fileUploadAllowed' => true,
],
],
],
'categories' => [
'config' => [
'type' => 'category'
]
],
],
];
+59 -5
View File
@@ -2,6 +2,12 @@
# VITEC container content elements — self-contained JSON rendering
# Children collected via Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor
# (exception-safe; returns [] on any error, never crashes the outer CE).
#
# Per-column flex settings (align/justify) live on the PARENT record
# (tx_vitec_col{1..4}_align/justify) and are attached by the processor to each
# column in `items` (items[].config.align/justify) — so they apply to ALL
# children of that column. The whole-grid gap (tx_vitec_gap) is emitted here as
# the parent-level `gap`.
# =============================================================================
tt_content.vitec_cols_50_50 = JSON
@@ -70,9 +76,27 @@ tt_content.vitec_cols_50_50 {
header.field = header
subheader = TEXT
subheader.field = subheader
headerLayout = INT
headerLayout.field = header_layout
headerPosition = TEXT
headerPosition.field = header_position
headerLink = TEXT
headerLink {
htmlSpecialChars = 1
typolink {
parameter.field = header_link
returnLast = result
}
}
tx_vitec_bg_variant = TEXT
tx_vitec_bg_variant.field = tx_vitec_bg_variant
# Parent-level: column gap scale (1–5) for the whole grid.
gap = TEXT
gap.field = tx_vitec_gap
# Columns with their children + per-column flex (align/justify) in
# items[].config — assembled by ContainerChildrenProcessor.
items = JSON
items {
dataProcessing {
@@ -83,18 +107,48 @@ tt_content.vitec_cols_50_50 {
}
}
tt_content.vitec_cols_33_33_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_25_25_25_25 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_66_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_33_66 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_33_33_33 < tt_content.vitec_cols_50_50
tt_content.vitec_cols_25_25_25_25 < tt_content.vitec_cols_50_50
tt_content.vitec_cols_66_33 < tt_content.vitec_cols_50_50
tt_content.vitec_cols_33_66 < tt_content.vitec_cols_50_50
# -----------------------------------------------------------------------------
# VITEC · Container — single-column container with a custom CSS class.
# Inherits the full structure of the grid containers and adds the `cssClass`
# field, read from the element's FlexForm (settings.cssClass).
# Single column -> no grid gap.
# -----------------------------------------------------------------------------
tt_content.vitec_container =< tt_content.vitec_cols_50_50
tt_content.vitec_container < tt_content.vitec_cols_50_50
tt_content.vitec_container.fields.gap >
tt_content.vitec_container.fields.cssClass = TEXT
tt_content.vitec_container.fields.cssClass.data = flexform:pi_flexform:settings.cssClass
tt_content.vitec_container.fields.cssClass.stdWrap.ifEmpty.cObject = TEXT
tt_content.vitec_container.fields.cssClass.stdWrap.ifEmpty.cObject.value =
# -----------------------------------------------------------------------------
# VITEC · Cards Carousel — container whose children (typically vitec_card
# elements) become the slides. Inherits the grid-container JSON structure
# (`items` via ContainerChildrenProcessor) and adds carousel settings read
# from the element's FlexForm. Single slide track -> no grid gap.
# -----------------------------------------------------------------------------
tt_content.vitec_cards_carousel < tt_content.vitec_cols_50_50
tt_content.vitec_cards_carousel.fields.gap >
tt_content.vitec_cards_carousel.fields.carousel = JSON
tt_content.vitec_cards_carousel.fields.carousel {
fields {
slidesPerView = TEXT
slidesPerView.data = flexform:pi_flexform:settings.slidesperview
showArrows = TEXT
showArrows.data = flexform:pi_flexform:settings.showarrows
showIndicators = TEXT
showIndicators.data = flexform:pi_flexform:settings.showindicators
loop = TEXT
loop.data = flexform:pi_flexform:settings.loop
autoplay = TEXT
autoplay.data = flexform:pi_flexform:settings.autoplay
autoplayInterval = TEXT
autoplayInterval.data = flexform:pi_flexform:settings.autoplayinterval
}
}
tt_content.vitec_cards_carousel.fields.cssClass = TEXT
tt_content.vitec_cards_carousel.fields.cssClass.data = flexform:pi_flexform:settings.cssClass
File mode changed.
@@ -0,0 +1,30 @@
# =============================================================================
# VITEC news integration — headless JSON output
#
# All supported EXT:news content element types use the same JSON renderer.
# The renderer returns a stable payload with:
# - mode = list | detail
# - items/news = serialised records
# - settings = the relevant FlexForm settings for the frontend
# =============================================================================
tt_content.news_pi1 =< lib.contentElementWithHeader
tt_content.news_pi1 {
fields {
content {
fields {
news = USER
news.userFunc = Evomedien\Vitec\UserFunc\NewsJsonRenderer->render
}
}
}
}
tt_content.news_newsliststicky < tt_content.news_pi1
tt_content.news_newsselectedlist < tt_content.news_pi1
tt_content.news_newsdetail < tt_content.news_pi1
tt_content.news_newsdatemenu < tt_content.news_pi1
tt_content.news_categorylist < tt_content.news_pi1
tt_content.news_newssearchform < tt_content.news_pi1
tt_content.news_newssearchresult < tt_content.news_pi1
tt_content.news_taglist < tt_content.news_pi1
+15
View File
@@ -80,6 +80,14 @@ mod {
CType = vitec_datasheets
}
}
eventlist {
iconIdentifier = vitec-plugin-eventlist
title = VITEC Event List
description = Upcoming VITEC events (trade shows) with date, venue, booth and links
tt_content_defValues {
CType = vitec_eventlist
}
}
simplecard {
iconIdentifier = vitec-plugin-simplecard
title = Simple Card
@@ -97,6 +105,13 @@ mod {
}
}
tx_news.templateLayouts {
1 = Compact List
2 = Featured Story
3 = Editorial Grid
4 = Detail Spotlight
}
############################################################
# Backend Previews für klassische Plugins (list_type)
############################################################
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 524 B

+16 -1
View File
@@ -13,13 +13,28 @@ fields:
type: Text
max: 50
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: bodytext
useExistingField: true
enableRichtext: true
View File
File mode changed.
@@ -50,7 +50,7 @@
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline fehlt</em>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
File mode changed.
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="3" y="6" width="32" height="20" rx="2" fill="none"/>
<rect x="3" y="6" width="12" height="20" rx="1.5" fill="#ff6a00"/>
<rect x="17" y="6" width="12" height="20" rx="1.5" fill="#0a3d62"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

@@ -0,0 +1,91 @@
name: vitec/columns
group: vitec_custom_components
prefixFields: true
prefixType: vendor
fields:
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: layout
type: Select
renderType: selectSingle
default: cols_50_50
items:
- label: '50 / 50'
value: cols_50_50
- label: '66 / 33'
value: cols_66_33
- label: '33 / 66'
value: cols_33_66
- identifier: items
type: Collection
label: Items
labelField: headline
fields:
- identifier: column
type: Select
renderType: selectSingle
default: left
items:
- label: Left column
value: left
- label: Right column
value: right
- identifier: item_type
type: Select
renderType: selectSingle
default: text
items:
- label: Text
value: text
- label: Image
value: image
- label: Video
value: video
- label: CTA / Button
value: cta
- identifier: headline
type: Text
max: 255
- identifier: text
type: Textarea
enableRichtext: true
- identifier: image
type: File
minitems: 0
maxitems: 1
allowed: common-image-types
- identifier: video
type: File
minitems: 0
maxitems: 1
allowed: mp4,webm,ogv,mov,m4v
- identifier: link
type: Link
allowedTypes:
- page
- url
- file
- email
- identifier: link_label
type: Text
max: 60
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2">
<file source-language="en" datatype="plaintext" original="messages">
<body>
<trans-unit id="title">
<source>VITEC · Columns</source>
</trans-unit>
<trans-unit id="description">
<source>Two-column layout (50/50, 66/33, 33/66). Add items and assign each to the left or right column.</source>
</trans-unit>
<trans-unit id="layout.label">
<source>Layout</source>
</trans-unit>
<trans-unit id="items.label">
<source>Items</source>
</trans-unit>
<trans-unit id="items.column.label">
<source>Column</source>
</trans-unit>
<trans-unit id="items.item_type.label">
<source>Type</source>
</trans-unit>
<trans-unit id="items.headline.label">
<source>Headline</source>
</trans-unit>
<trans-unit id="items.text.label">
<source>Text</source>
</trans-unit>
<trans-unit id="items.image.label">
<source>Image</source>
</trans-unit>
<trans-unit id="items.video.label">
<source>Video</source>
</trans-unit>
<trans-unit id="items.link.label">
<source>Link</source>
</trans-unit>
<trans-unit id="items.link_label.label">
<source>Link Label</source>
</trans-unit>
</body>
</file>
</xliff>
@@ -0,0 +1,19 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:layout name="Preview"/>
<f:section name="Content">
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
<div class="vitec-preview">
<div class="vitec-preview__thumb-placeholder">▦</div>
<div class="vitec-preview__body">
<div class="vitec-preview__label">VITEC · Columns</div>
<h3 class="vitec-preview__headline">Two-column layout — {data.layout}</h3>
</div>
<div class="vitec-preview__settings">
<span class="vitec-badge">{data.layout}</span>
</div>
</div>
</f:section>
</html>
@@ -0,0 +1,3 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<!-- Headless mode: JSON is built by the VITEC UsecaseSerializer / nb-headless-content-blocks -->
</html>
+17
View File
@@ -3,10 +3,27 @@ group: vitec_custom_components
prefixFields: true
prefixType: vendor
fields:
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: bodytext
useExistingField: true
@@ -6,7 +6,7 @@
<source>VITEC · CTA Banner</source>
</trans-unit>
<trans-unit id="description">
<source>Call-to-Action Banner mit optionalem zweiten Button und Background-Varianten</source>
<source>Call-to-action banner with optional secondary button and background variants</source>
</trans-unit>
<trans-unit id="primary_cta.label">
@@ -35,7 +35,7 @@
<source>Background Image (optional)</source>
</trans-unit>
<trans-unit id="background_image.description">
<source>Optionales Hintergrundbild — überlagert die Background-Variant-Farbe</source>
<source>Optional background image — overlays the background variant colour</source>
</trans-unit>
</body>
</file>
@@ -6,7 +6,7 @@
<div class="vitec-preview vitec-preview--{data.background_variant}">
<f:comment>Thumbnail (Background Image oder Farb-Placeholder)</f:comment>
<f:comment>Thumbnail (background image or colour placeholder)</f:comment>
<f:if condition="{data.background_image.0}">
<f:then>
<f:image image="{data.background_image.0}"
@@ -37,7 +37,7 @@
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline fehlt</em>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
@@ -75,7 +75,7 @@
</f:switch>
</span>
<f:comment>Layout Variant Badge mit Icon</f:comment>
<f:comment>Layout variant badge with icon</f:comment>
<span class="vitec-badge">
<f:switch expression="{data.layout_variant}">
<f:case value="centered">◎ Centered</f:case>
@@ -85,9 +85,9 @@
</f:switch>
</span>
<f:comment>Warnung wenn Primary-CTA-Link fehlt</f:comment>
<f:comment>Warning when primary CTA link is missing</f:comment>
<f:if condition="{data.primary_cta.url} == ''">
<span class="vitec-badge vitec-badge--warning">⚠ Kein Primary-Link</span>
<span class="vitec-badge vitec-badge--warning">⚠ Primary link missing</span>
</f:if>
</div>
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<rect x="1.5" y="2" width="13" height="3.4" rx="0.8" fill="#000" opacity="0.55"/>
<polygon points="12.2,3.2 13.6,3.2 12.9,4.4" fill="#fff"/>
<rect x="1.5" y="6.3" width="13" height="3.4" rx="0.8" fill="none" stroke="#000" stroke-width="1" opacity="0.6"/>
<polygon points="12.4,7.2 12.4,8.6 13.4,7.9" fill="#000" opacity="0.6"/>
<rect x="1.5" y="10.6" width="13" height="3.4" rx="0.8" fill="none" stroke="#000" stroke-width="1" opacity="0.6"/>
<polygon points="12.4,11.5 12.4,12.9 13.4,12.2" fill="#000" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

@@ -0,0 +1,56 @@
name: vitec/faq
group: vitec_custom_components
prefixFields: true
prefixType: vendor
fields:
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: faq_items
type: Collection
labelField: question
minitems: 1
fields:
- identifier: question
type: Text
required: true
max: 255
- identifier: answer
type: Textarea
enableRichtext: true
- identifier: first_item_open
type: Checkbox
default: 1
- identifier: allow_multiple_open
type: Checkbox
default: 0
- identifier: debug
type: Checkbox
default: 0
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2">
<file source-language="en" datatype="plaintext" original="messages">
<body>
<trans-unit id="title">
<source>VITEC · FAQ</source>
</trans-unit>
<trans-unit id="description">
<source>Accordion with repeatable question/answer items. First item can start expanded.</source>
</trans-unit>
<trans-unit id="faq_items.label">
<source>FAQ Entries</source>
</trans-unit>
<trans-unit id="faq_items.question.label">
<source>Question</source>
</trans-unit>
<trans-unit id="faq_items.answer.label">
<source>Answer</source>
</trans-unit>
<trans-unit id="first_item_open.label">
<source>Open first item by default</source>
</trans-unit>
<trans-unit id="allow_multiple_open.label">
<source>Allow multiple items open at once</source>
</trans-unit>
<trans-unit id="debug.label">
<source>Allow Debug Output</source>
</trans-unit>
</body>
</file>
</xliff>
@@ -0,0 +1,66 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:layout name="Preview"/>
<f:section name="Content">
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
<div class="vitec-preview">
<div class="vitec-preview__thumb-placeholder">?</div>
<div class="vitec-preview__body">
<div class="vitec-preview__label">VITEC · FAQ</div>
<h3 class="vitec-preview__headline">
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
<f:if condition="{data.subheader}">
<div class="vitec-preview__subline">{data.subheader}</div>
</f:if>
<f:if condition="{data.faq_items}">
<f:then>
<div class="vitec-preview__body-text">
<f:for each="{data.faq_items}" as="item" iteration="i">
<f:if condition="{i.index} < 3">▸ {item.question}<br/></f:if>
</f:for>
</div>
</f:then>
<f:else>
<div class="vitec-preview__body-text">
<em style="color:#c00;">⚠ No FAQ entries</em>
</div>
</f:else>
</f:if>
</div>
<div class="vitec-preview__settings">
<span class="vitec-badge">
❓ <f:count subject="{data.faq_items}"/> entries
</span>
<f:if condition="{data.first_item_open}">
<span class="vitec-badge">▽ First item open</span>
</f:if>
<f:if condition="{data.allow_multiple_open}">
<span class="vitec-badge">≡ Multiple open allowed</span>
</f:if>
<f:if condition="{data.debug}">
<span class="vitec-badge vitec-badge--warning">⚙ Debug ON</span>
</f:if>
</div>
</div>
</f:section>
</html>
@@ -0,0 +1,3 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<!-- Headless mode: JSON is built by nb-headless-content-blocks -->
</html>
+13 -4
View File
@@ -8,22 +8,31 @@ fields:
type: Text
max: 50
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: bodytext
useExistingField: true
enableRichtext: true
File mode changed.
@@ -31,7 +31,7 @@
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline fehlt</em>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
@@ -53,7 +53,7 @@
</f:if>
</div>
<f:comment>Settings Sidebar (ohne Partial)</f:comment>
<f:comment>Settings sidebar (without partial)</f:comment>
<div class="vitec-preview__settings">
<span class="vitec-badge">
<span class="vitec-badge__dot vitec-badge__dot--{data.background_variant}"></span>
@@ -65,7 +65,7 @@
</f:if>
<f:if condition="{data.hero_image.0} == ''">
<span class="vitec-badge vitec-badge--warning">⚠ Kein Bild</span>
<span class="vitec-badge vitec-badge--warning">⚠ No image</span>
</f:if>
</div>
File mode changed.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 536 B

+13 -4
View File
@@ -3,23 +3,32 @@ group: vitec_custom_components
prefixFields: true
prefixType: vendor
fields:
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: bodytext
useExistingField: true
enableRichtext: true
File mode changed.
@@ -12,7 +12,7 @@
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline fehlt</em>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
@@ -61,7 +61,7 @@
</f:if>
<f:if condition="{data.button_text} && {data.button_link.url} == ''">
<span class="vitec-badge vitec-badge--warning">⚠ Button-Text ohne Link</span>
<span class="vitec-badge vitec-badge--warning">⚠ Button text without link</span>
</f:if>
<f:if condition="{data.debug}">
File mode changed.
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

+13 -4
View File
@@ -3,23 +3,32 @@ group: vitec_custom_components
prefixFields: true
prefixType: vendor
fields:
- identifier: header_section
type: Palette
label: Header
fields:
- identifier: header
useExistingField: true
required: true
- type: Linebreak
- identifier: header_layout
useExistingField: true
- identifier: header_position
useExistingField: true
- identifier: date
useExistingField: true
- type: Linebreak
- identifier: header_link
useExistingField: true
- type: Linebreak
- identifier: subheader
useExistingField: true
- identifier: youtube_code
type: Text
max: 50
View File
File mode changed.
@@ -27,7 +27,7 @@
<f:if condition="{data.header}">
<f:then>{data.header}</f:then>
<f:else>
<em style="color:#c00;">⚠ Headline fehlt</em>
<em style="color:#c00;">⚠ Headline missing</em>
</f:else>
</f:if>
</h3>
@@ -59,7 +59,7 @@
</f:if>
<f:if condition="!{data.youtube_code} && !{data.custom_video.0}">
<span class="vitec-badge vitec-badge--warning">⚠ Kein Video-Source</span>
<span class="vitec-badge vitec-badge--warning">⚠ No video source</span>
</f:if>
<f:if condition="{data.hide_controls}">
<span class="vitec-badge">⏸ Controls hidden</span>
File mode changed.
@@ -0,0 +1,619 @@
# VITEC Headless JSON Architecture — Software Architecture and Interface Specification
| | |
|---|---|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
| **Version** | 1.0 |
| **Status** | Released |
| **Date** | 2026‑07‑09 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch |
This document is drafted in the style of, and adopts the terminology conventions of,
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
users) and ISO/IEC 25010 (product quality). The key words **shall**, **should** and
**may** are to be interpreted as normative requirements, recommendations and
permissions respectively.
---
## Foreword
The VITEC web platform is a *headless* TYPO3 installation: the CMS does not render
HTML pages, it emits JSON that is consumed by a separate React front end. This
specification describes the architecture, the public JSON interface, and the
engineering conventions that keep the JSON output **consistent** across all content
types and **maintainable** across TYPO3 and extension upgrades.
It supersedes, as the authoritative reference, the informal tutorial
`Documentation/HeadlessIntegration.md`, which is retained as an informative how‑to.
## Introduction
The platform combines the generic headless page renderer (`friendsoftypo3/headless`)
with three sources of content JSON:
1. **Custom plugins** (product, use case/success story, market, solution, downloads,
datasheets, events, news) rendered by dedicated *UserFunc* classes;
2. **Layout containers** (b13/container based column grids and a card carousel)
rendered by a *DataProcessor*;
3. **Content Blocks** (`friendsoftypo3/content-blocks`) serialised automatically by
`nb-headless-content-blocks`.
All three are unified under a single **content‑element envelope** so that the front
end can consume every element with one predictable shape.
---
## 1 Scope
### 1.1 In scope
This document specifies:
- the runtime environment and the software stack (Clause 5);
- the JSON rendering pipeline and its layers (Clause 6);
- the public JSON interface — envelope, payloads, structured data (Clause 7);
- the catalogue of content types and their JSON keys (Clause 8);
- the mandatory conventions for implementing and extending renderers (Clause 9);
- maintainability and upgrade‑safety requirements (Clause 10);
- conformance criteria (Clause 11).
### 1.2 Out of scope
Front‑end (React) implementation, hosting/deployment, the editorial (backend) TCA
form design except where it determines JSON output, and non‑headless (Fluid) rendering
paths.
## 2 Normative references
The following documents are referred to in the text. For dated references, only the
edition cited applies.
- ISO/IEC/IEEE 42010, *Software, systems and enterprise — Architecture description*
- ISO/IEC 25010, *Systems and software Quality Requirements and Evaluation (SQuaRE) — Product quality model*
- ISO/IEC/IEEE 26514, *Systems and software engineering — Design and development of information for users*
- ISO 8601‑1, *Date and time — Representations for information interchange*
- IETF RFC 8259, *The JavaScript Object Notation (JSON) Data Interchange Format*
- IETF RFC 2119, *Key words for use in RFCs to indicate requirement levels*
- schema.org vocabulary (informative), *https://schema.org*
## 3 Terms and definitions
**3.1 headless** — operating mode in which TYPO3 returns JSON instead of HTML;
enabled per site by `headless: 1` and the headless Site Sets.
**3.2 content element** — a `tt_content` record; the atomic unit of page content.
**3.3 CType** — the content‑element type identifier stored in `tt_content.CType`
(e.g. `vitec_productlist`, `news_pi1`, `vitec_cols_50_50`).
**3.4 envelope** — the invariant outer JSON structure shared by every content element
(Clause 7.2).
**3.5 payload** — the domain‑specific JSON produced for one content element and placed
inside the envelope (Clause 7.3).
**3.6 renderer** — a *UserFunc* class under `Classes/UserFunc/` that produces a payload.
**3.7 serializer** — a service class under `Classes/Service/` that converts a domain
record into JSON, used by one or more renderers.
**3.8 resolver / processor** — `ContentElementResolver` and `ContainerChildrenProcessor`;
they normalise a raw `tt_content` row into an envelope and resolve nested elements.
**3.9 container** — a b13/container CType that owns child content elements via
`tx_container_parent` and emits them as `items`.
**3.10 Content Block** — a declaratively defined content element
(`friendsoftypo3/content-blocks`), serialised by `nb-headless-content-blocks`.
## 4 Symbols and abbreviated terms
| Term | Meaning |
|---|---|
| FAL | File Abstraction Layer (TYPO3 file handling) |
| IRRE | Inline Relational Record Editing (`type: inline`) |
| MM | Many‑to‑many junction table |
| CB | Content Block |
| TS | TypoScript |
| CE | Content element |
---
## 5 Runtime environment (architecture context)
### 5.1 Software stack
| Component | Version | Role |
|---|---|---|
| TYPO3 CMS | ^14.3 | Core CMS |
| PHP | 8.x (per TYPO3 14) | Runtime |
| `friendsoftypo3/headless` | ^5.0@rc | Page‑to‑JSON renderer, `lib.contentElement` |
| `friendsoftypo3/content-blocks` | ^2.4 | Declarative content elements |
| `netzbewegung/nb-headless-content-blocks` | ^0.0.23 | Content Blocks → JSON |
| `b13/container` | ^3.1 | Nested column containers |
| `georgringer/news` | ^14.0 | News records and plugins |
| `evomedien/vitec` | ^1.0 | This project’s custom extension |
> **NOTE** `friendsoftypo3/headless` is pinned to a **release candidate** (`^5.0@rc`).
> This is an upgrade‑sensitivity point; see 10.4.
### 5.2 Site configuration
The headless mode is activated in `config/sites/vitec/config.yaml`:
```yaml
base: /
headless: 1
frontendBase: ''
dependencies:
- friendsoftypo3/headless
- friendsoftypo3/headless-mixed
- nb-headless-content-blocks/headless-content-blocks
- georgringer/news
```
The Site **Sets** listed under `dependencies` load, in order, the headless TypoScript
base, the mixed‑mode overrides, the Content Blocks JSON integration and the News
integration. The VITEC Set (`EXT:vitec/Configuration/Sets/Vitecset`) layers the custom
definitions on top. The headless page response carries
`Content-Type: application/json; charset=utf-8`. Slug routing is configured with route
enhancers for products (`tx_vitec_domain_model_product.slug`) and news detail
(`path_segment`).
### 5.3 Architectural principles (rationale)
- **P1 — One envelope.** Every content element, regardless of source, is exposed with
the same outer shape so the front end has a single rendering contract.
- **P2 — Payload isolation.** Domain JSON is produced in PHP, fully decoupled from
TypoScript, so business logic is testable and versionable.
- **P3 — Dual entry.** Each renderer works both as a top‑level plugin and as a nested
child of a container (Clause 9.3).
- **P4 — Fail soft.** A failing element yields empty output, never a broken page
(Clause 9.5).
---
## 6 Rendering pipeline
The JSON for one page is assembled top‑down through the following layers.
```
HTTP request (Accept: application/json, headless:1)
│
▼
[L1] Page renderer friendsoftypo3/headless
│ builds { meta, content[], … , jsonLd }
▼
[L2] Content‑element envelope lib.contentElement / lib.contentElementWithHeader
│ per CType: id, type, colPos, appearance, content{header,…}
▼
[L3] Payload injection USER cObj → Classes/UserFunc/*JsonRenderer::render
│ places domain JSON under content.<key>
▼
[L4] Containers tt_content.vitec_cols_* = JSON
│ ContainerChildrenProcessor → items[].contentElements[]
▼
[L5] Normalisation & nesting ContentElementResolver / PLUGIN_RENDERERS
▼
[L6] Structured data (JSON‑LD) PageJsonLdRenderer + StructuredDataService
```
### 6.1 L1 — Page renderer
`friendsoftypo3/headless` converts the requested page into a JSON document containing
page metadata, the ordered array of content elements, navigation and the JSON‑LD
graph. VITEC does not replace this layer; it contributes elements to `content[]`
(L2–L5) and the `jsonLd` field (L6).
### 6.2 L2 — Content‑element envelope
Each CType is bound to a headless library object:
```typoscript
tt_content.<ctype> < lib.contentElementWithHeader
```
`lib.contentElement` provides `id`, `type`, `colPos`, `categories`, `appearance`.
`lib.contentElementWithHeader` additionally provides, under `content`, the standard
header fields: `header`, `subheader`, `headerLayout`, `headerPosition`, `headerLink`
(link resolved via typolink). **All VITEC plugins and all News CTypes inherit
`lib.contentElementWithHeader`**, giving a uniform header section in both backend and
JSON (see the companion header convention).
### 6.3 L3 — Payload injection
The domain payload is added as a `USER` content object under `content.fields.<key>`:
```typoscript
tt_content.vitec_productlist < lib.contentElementWithHeader
tt_content.vitec_productlist {
fields {
content {
fields {
products = USER
products.userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
}
}
}
}
```
`render()` returns a JSON string that the headless JSON cObject embeds verbatim at
`content.products`. The key is **plural for list plugins** and **singular for detail
plugins** (Clause 9.6).
### 6.4 L4 — Containers
Column containers are defined as a self‑contained JSON object, not via
`lib.contentElement`:
```typoscript
tt_content.vitec_cols_50_50 = JSON
tt_content.vitec_cols_50_50.fields {
id … type … appearance … header … subheader …
gap = TEXT # tx_vitec_gap (whole‑grid gap)
items = JSON
items.dataProcessing.10 = Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor
}
tt_content.vitec_cols_33_66 < tt_content.vitec_cols_50_50
tt_content.vitec_container < tt_content.vitec_cols_50_50 # single column, no gap
tt_content.vitec_cards_carousel < tt_content.vitec_cols_50_50 # + carousel settings
```
> **RULE (normative)** Container CTypes **shall** be derived with the **copy**
> operator `<`, never the reference operator `=<`. A `tt_content → tt_content`
> reference is not recognised as an independent renderer by the headless content
> mapper and silently falls back to raw output (see 10.5, and Annex B‑1).
### 6.5 L5 — Normalisation and nested plugins
`ContainerChildrenProcessor` queries children by `tx_container_parent`, groups them by
`colPos` and emits, per column, a flex configuration plus the resolved children. Each
child is normalised by the same envelope logic as `ContentElementResolver`. When a
child is itself a VITEC plugin, it is resolved through the shared `PLUGIN_RENDERERS`
map (Clause 7.4).
### 6.6 L6 — Structured data (JSON‑LD)
`PageJsonLdRenderer` (bound at `page…fields.jsonLd`) assembles a schema.org `@graph`
via `StructuredDataService`, encoded with `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`.
Node types and their triggers:
| Node | Emitted when |
|---|---|
| `Organization` | every page (from site settings `seo.organization.*`) |
| `WebSite` | only on the site root page |
| `BreadcrumbList` | from the rootline (spacer/folder/recycler doktypes skipped) |
| `NewsArticle` | on news‑detail page layouts (`pages.layout ∈ {13,14,15}`) |
| `FAQPage` | when the page contains `vitec_faq` elements → `vitec_faq_items` collection |
| `ExhibitionEvent` | from `vitec_eventlist` elements → `tx_vitec_domain_model_event` (windowed by the FlexForm `daysinadvance`) |
| `Product`, `VideoObject` | via `ProductShowJsonRenderer` for product detail pages |
FAQ and event nodes are collected from the respective tables; event image URLs are
currently built with a hard‑coded `/fileadmin` prefix (see 10.3 / Annex B‑5).
---
## 7 JSON interface specification
### 7.1 Encoding
Output **shall** be RFC 8259 JSON, UTF‑8. Timestamps **shall** be Unix epoch seconds
(integer); where ISO 8601 strings are required by schema.org they are produced inside
the JSON‑LD layer.
### 7.2 Content‑element envelope
Every element in `content[]` conforms to:
```jsonc
{
"id": 123, // tt_content.uid
"type": "vitec_productlist", // tt_content.CType
"colPos": 0,
"appearance": {
"layout": "0",
"frameClass": "default",
"spaceBefore": "",
"spaceAfter": ""
},
"content": { // present for lib.contentElement(WithHeader) CTypes
"header": "…", "subheader": "…",
"headerLayout": 2, "headerPosition": "", "headerLink": "https://…",
"<key>": { /* payload, Clause 7.3 */ }
}
}
```
Elements produced by the resolver/processor (container children, inline story CEs) use
a lean variant of the envelope:
```jsonc
{ "id": 456, "type": "text", "colPos": 211, "sorting": 1,
"appearance": { … }, "data": { /* non‑system fields */ } }
```
### 7.3 Payload keys
| Kind | Key | Cardinality |
|---|---|---|
| List plugin | plural noun (`products`, `usecases`, `news → items`) | array |
| Detail plugin | singular noun (`product`, `usecase`, `market`, `solution`) | object |
| Container | `items` | array of `{config, contentElements}` |
### 7.4 Container payload
```jsonc
{
"type": "vitec_cols_33_66",
"header": "…", "headerLayout": 2, "headerLink": "…",
"gap": "3",
"items": [
{ "config": { "colPos": 251, "align": "stretch", "justify": "flex-start" },
"contentElements": [ /* normalised children, recursively */ ] },
{ "config": { "colPos": 252, "align": "center", "justify": "space-between" },
"contentElements": [ … ] }
]
}
```
- `gap` is a **parent‑level** property (whole‑grid gap).
- `align`/`justify` are **per‑column** and are read from the parent record
(`tx_vitec_col{N}_align/justify`); they therefore apply to **all** children of that
column. These container‑only fields **shall not** appear in a child’s `data`
(enforced by `CONTAINER_FIELDS` filtering).
### 7.5 Success Story (use case) detail payload
Produced by `UsecaseSerializer::serializeDetail()` — the reference implementation of
the centralised pattern (Clause 9.2):
```jsonc
{
"uid": 1, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
"cardImage": { "url": "…", "srcset": [ … ] }, "customerLogo": { … },
"hero": { "bgImage": …, "smallImage": …, "video": …,
"overlayColor": "#000", "overlayOpacity": 0.4,
"layout": "fullscreen", "textTheme": "light" },
"contentElements": [ /* inline CEs; the vitec_columns block is resolved specially */ ],
"related": { "show": true, "market": {…}, "solutions": [ … ], "products": [ … ],
"categories": [ … ] },
"seo": { "title": "…", "description": "…", "canonical": "…",
"robots": { "noIndex": false, "noFollow": false },
"openGraph": { "title": …, "description": …, "image": … },
"twitter": { "title": …, "description": …, "image": … } },
"appearance": { "layoutVariant": "standard", "backgroundVariant": "none",
"accentColor": "", "featured": false, … }
}
```
SEO fields use fallback resolution (`seo_* → title/teaser`; `og_* → seo_* → title`;
`twitter_* → og_*`; images `og_image → card_image → hero_bgimage`) so the front end
always receives complete metadata.
### 7.6 News payload
Produced by `NewsJsonRenderer` under `content.news`:
```jsonc
{ "mode": "list", // "list" | "detail" | "error"
"items": [ { "uid": 1, "title": "…", "alternativeTitle": "…",
"pathSegment": "…", "detailUrl": "/news/…",
"canonicalUrl": "https://…", "teaser": "…", "bodytext": "…",
"datetime": 1625097600, "categories": [ … ], "media": [ … ] } ],
"settings": { "type": "news_pi1", "templateLayout": "1",
"templateLayoutLabel": "Compact List", "detailPid": 45, … } }
```
Ten News CTypes (`news_pi1`, `news_newsdetail`, `news_newsliststicky`, …) share one
renderer; `news_newsdetail` yields `mode: "detail"` with a single `news` object.
### 7.7 Content Blocks
Content Blocks (`card`, `cta-banner`, `hero-section`, `intro-paragraph`, `video`,
`faq`, `columns`) are serialised automatically by `nb-headless-content-blocks`: its
`ContentBlocksJsonDataProcessor` converts the resolved record via `RecordToArray`,
dropping system fields and recursing into files (→ URL + metadata), collections
(e.g. `faq_items` → array of child records) and typolinks. The output uses the lean
envelope (`{id, type, colPos, sorting, appearance, data}`). An optional per‑block
`headless.php` hook may transform the array (none are currently defined). Fields carry
the `vitec_` vendor prefix in storage. The **`vitec_columns`** block is
the single exception: because it is authored **inline inside a Success Story record**,
its collection items are resolved explicitly by `UsecaseSerializer::resolveColumnsElement()`
into `{ header…, layout, columns: { left: [], right: [] } }`, with the collection
storage table discovered from TCA (`foreign_table`) rather than hard‑coded.
---
## 8 Content‑type catalogue
| CType | TS pattern | Renderer / Processor | Payload key | Kind |
|---|---|---|---|---|
| `vitec_productlist` | `< lib.contentElementWithHeader` | ProductListJsonRenderer | `products` | list |
| `vitec_productshow` | `< lib.…WithHeader` | ProductShowJsonRenderer | `product` | detail |
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
| `vitec_marketshow` | `< lib.…WithHeader` | MarketShowJsonRenderer | `market` | detail |
| `vitec_solutionshow` | `< lib.…WithHeader` | SolutionShowJsonRenderer | `solution` | detail |
| `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail |
| `vitec_downloadcardcollection` | `< lib.…WithHeader` | DownloadcardcollectionJsonRenderer | `downloadcardcollection` | list |
| `vitec_datasheets` | `< lib.…WithHeader` | DatasheetsJsonRenderer | `datasheets` | list |
| `vitec_eventlist` | `=< lib.…WithHeader` ⚠ | EventlistJsonRenderer | `eventlist` | list |
| `news_pi1` (+9 variants) | `=< lib.…WithHeader`; variants `<` | NewsJsonRenderer | `news` | list/detail |
| `vitec_cols_50_50` | `= JSON` | ContainerChildrenProcessor | `items` | container |
| `vitec_cols_33_66 / 66_33 / 33_33_33 / 25_25_25_25` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container |
| `vitec_container` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container (1 col) |
| `vitec_cards_carousel` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` + `carousel` | container |
| Content Blocks (`vitec_card`, …) | Content Blocks + nb‑headless | — | (auto) | element |
| `vitec_columns` (CB, inline) | Content Blocks | UsecaseSerializer (special) | `columns` | element |
⚠ = uses the reference operator `=<`; see Annex B‑1.
---
## 9 Conventions (normative)
These rules define the **single, uniform way** to implement and extend headless
renderers. New code **shall** comply; existing code **should** be aligned when touched.
### 9.1 Renderer class shape
A payload renderer **shall**:
1. reside in `Classes/UserFunc/` and be named `<Domain><Kind>JsonRenderer`;
2. expose `#[AsAllowedCallable] public function render(string $content, array $conf): string`;
3. expose `public function renderForRecord(array $row): string` for reuse by
containers/resolvers (Clause 9.3);
4. return a JSON **string** (never an array/object).
### 9.2 Serialisation ownership
Domain‑to‑JSON conversion **should** live in a `Classes/Service/*Serializer` class, and
the renderer **should** be a thin wrapper around it. `UsecaseSerializer` is the
reference implementation. New list/detail pairs **shall** share one serializer.
### 9.3 Dual‑entry pattern
`render()` **shall** handle top‑level invocation: use `$this->cObj->data` when it is the
plugin’s own row, otherwise perform page discovery (Clause 9.4). `renderForRecord()`
**shall** accept an explicit `tt_content` row and be free of page/context assumptions,
so it can be called by `ContainerChildrenProcessor` and `ContentElementResolver`.
### 9.4 Page‑id discovery
Renderers **shall** resolve the current page id in this order:
```php
$id = $GLOBALS['TYPO3_REQUEST']?->getAttribute('frontend.page.information')?->getId() ?? 0;
if ($id <= 0) { $id = (int)($GLOBALS['TSFE']->id ?? 0); } // fallback
```
Reliance on `$GLOBALS['TSFE']->id` alone is **prohibited** (it is frequently `null` in
the JSON cObject context).
### 9.5 Fail‑soft error handling
The body of `renderForRecord()` **shall** be wrapped in
`try { … } catch (\Throwable $e) { return ''; }`. Diagnostic output **may** be emitted
only when a FlexForm `debug` flag is set. A failing element **shall not** propagate an
exception to the page.
### 9.6 Naming
- List payload keys **shall** be plural; detail keys **shall** be singular.
- JSON property names **shall** be `camelCase` for computed/composed fields; raw
passthrough fields in `data` retain their database names.
### 9.7 Nested‑plugin registration
A plugin that may appear inside a container **shall** be registered in the
`PLUGIN_RENDERERS` map in **both** `ContentElementResolver` and
`ContainerChildrenProcessor`, as `CType => [RendererClass::class, 'jsonKey']`.
> **NOTE** The duplicated map is a known maintenance hazard (10.3). Until it is
> centralised, both copies **shall** be kept in sync.
### 9.8 Header section
Every custom CE/plugin/container **shall** expose the standard header section (the core
`headers` palette; Content Blocks use a `header_section` palette). See the companion
"header convention". Header fields in JSON use the names `header`, `subheader`,
`headerLayout`, `headerPosition`, `headerLink`.
---
## 10 Maintainability and upgrade‑safety (ISO 25010)
This clause records the quality characteristics *maintainability* and *portability*
and the concrete risks and rules that preserve them.
### 10.1 Modularity — current state
Strengths: uniform envelope, dual‑entry pattern, exception safety, a shared
`PLUGIN_RENDERERS` map, and the `UsecaseSerializer` reference pattern.
Weakness: **substantial duplication** across the inline renderers.
### 10.2 Reusability — duplication register
The following logic is duplicated across many renderers and **should** be extracted
into shared services (target design in parentheses):
| Duplicated logic | Occurrences | Target service |
|---|---|---|
| FAL image/`srcset` resolution | Product(List/Show), Market, Solution, Event, Datasheets | `FalImageResolver` |
| FAL video resolution | Product, Usecase, hero | `FalImageResolver::video()` |
| `sys_category` MM query | ≥ 9 renderers | `CategoryResolver` |
| Custom MM (product↔download, …) | ≥ 5 renderers | `RelationResolver::resolveMany()` |
| Download file‑by‑convention | 5 renderers | `ConventionFileResolver` |
| `letterSequenceToRank()` sort helper | 5 renderers | static utility |
| Page‑id discovery | all renderers | `PageIdResolver::resolve()` |
> **RULE** When a shared resolver service exists, new renderers **shall** use it and
> **shall not** re‑implement the logic inline.
### 10.3 Analysability — single sources of truth
- The `PLUGIN_RENDERERS` map exists in two files (10.2/9.7); it **should** be promoted
to one shared constant/class.
- Table and field names are string literals scattered across renderers. New code
**shall** define table/field names as **class constants** (as `UsecaseSerializer`
and the processors already do) to localise upgrade impact.
### 10.4 Portability — upgrade‑sensitivity points
| Point | Risk | Mitigation |
|---|---|---|
| `friendsoftypo3/headless ^5.0@rc` | RC; `lib.contentElement(WithHeader)` shape may change | Pin exact RC; re‑verify envelope after any bump; keep payloads decoupled (P2) |
| `nb-headless-content-blocks ^0.0.x` | pre‑1.0; CB→JSON shape and collection storage may change | `vitec_columns` resolution reads the table from TCA (`foreign_table`) — do **not** hard‑code CB tables |
| `georgringer/news ^14` | 10 News CTypes hard‑mapped in `NewsJsonRenderer` | Keep the CType→layout map in one place; re‑verify on major news upgrade |
| `b13/container ^3.1` | child linkage via `tx_container_parent`; page‑module grid required | Documented limitation: containers cannot be authored inside IRRE (Annex B‑2) |
### 10.5 Modifiability — mandatory rules distilled
1. Container CTypes **shall** use `<` (copy), never `=<` (reference) — see Annex B‑1.
2. Table/field names **shall** be class constants, not inline literals.
3. Renderers **shall** reuse shared resolver services once they exist.
4. Magic numeric literals (e.g. a hard‑coded parent‑category uid) **shall** be replaced
by named constants or configuration.
---
## 11 Conformance
An implementation conforms to this specification if, for every content type it
exposes:
- **C1** the output validates as RFC 8259 JSON and matches the envelope of 7.2;
- **C2** the responsible renderer satisfies the class shape of 9.1 and the dual‑entry
pattern of 9.3;
- **C3** page‑id discovery follows 9.4 and error handling follows 9.5;
- **C4** payload keys follow 9.6 and the header section follows 9.8;
- **C5** container derivation follows the `<`‑copy rule of 6.4/10.5(1);
- **C6** any container‑nestable plugin is registered per 9.7.
Deviations are recorded in Annex B and **shall** carry a remediation plan.
---
## Annex A (normative) — Checklist: adding a new headless plugin
1. **Model/TCA/SQL** — create the domain table and TCA; define table/field names as
constants.
2. **Serializer** — add `Classes/Service/<Domain>Serializer` with `serializeListItem()`
and/or `serializeDetail()`; reuse existing resolver services.
3. **Renderer** — add `Classes/UserFunc/<Domain><Kind>JsonRenderer` per 9.1, delegating
to the serializer; implement `render()` (9.3/9.4) and `renderForRecord()`.
4. **TypoScript** — in `Configuration/Sets/Vitecset/setup.typoscript`:
`tt_content.<ctype> < lib.contentElementWithHeader` and
`content.fields.<key> = USER` + `.userFunc = …->render`.
5. **Header section** — ensure the `headers` palette is present (9.8).
6. **Nesting** — if the plugin may sit inside a container, register it in
`PLUGIN_RENDERERS` in **both** the resolver and the processor (9.7).
7. **Deploy** — `vendor/bin/typo3 database:updateschema "*.add,*.change"` then
`vendor/bin/typo3 cache:flush`.
8. **Verify** — fetch the page JSON; confirm envelope (C1), keys (C4) and nested output.
---
## Annex B (informative) — Nonconformity and technical‑debt register
The following items were identified during architecture analysis. Items marked
*(to verify)* were reported by static review and **shall** be confirmed before
remediation.
- **B‑1 — `=<` on Event/News CTypes.** `vitec_eventlist` and `news_pi1` use the
reference operator `=< lib.contentElementWithHeader`. Reference *to a `lib.*` object*
is used by headless itself and is generally safe; however, for consistency with
10.5(1) these are VERIFIED SAFE and left as-is (references to lib.* are idiomatic in headless; only tt_content-to-tt_content references are unsafe, and those are already `<` for containers).
- **B‑2 — Containers cannot be authored inline (IRRE).** b13 container children live in
`tx_container_parent` and require the page‑module grid; they cannot be created inside
an inline field. This is a platform limitation, not a defect. The Success Story
"Columns" Content Block (`vitec_columns`) is the sanctioned inline alternative.
- **B‑3 — Duplicated `PLUGIN_RENDERERS` map** in `ContentElementResolver` and
`ContainerChildrenProcessor` (9.7/10.3).
- **B‑4 — Inline duplication** of image/category/MM/page‑id logic (10.2).
- **B‑5 — Hard‑coded literals** — model/MM table names, a `/fileadmin` prefix for
event JSON‑LD image URLs (`PageJsonLdRenderer`), and at least one magic
parent‑category uid appear as inline literals across download/datasheet/structured‑data
code *(to verify and extract to constants)*. `COLPOS_TO_COLUMN` in
`ContainerChildrenProcessor` must be kept in sync with the container TCA colPos values.
- **B‑6 — `DownloadcardcollectionJsonRenderer`**: two misplaced thumbnail output lines referenced an undefined variable in the image resolver. FIXED 2026-07-09 (removed; the PDF-thumbnail method is unaffected).
- **B‑7 — v13→v14 `CType`/`list_type` compatibility branches** in some download/
datasheet renderers FIXED 2026-07-09: the unsatisfiable legacy OR branch was removed; the query now filters on the v14 CType only.
---
## Annex C (informative) — Related documents
- `Documentation/HeadlessIntegration.md` — informal how‑to (superseded as the
authoritative reference by this document).
- Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVO‑VITEC‑HL‑001 v1.0.*
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more