Files
VITEC-website/packages/vitec/Documentation/Headless-JSON-Architecture.md
T
orascheandClaude Opus 5.5 da2ebdca23 Product detail: resources section layout 3 "Hardware option"
- The Resources Section Layout select gained a third value, hardware
  ("Layout 3 - Hardware option"): Technical Highlights spread over two
  columns, no resource cards
- ProductShowJsonRenderer::resourcesBand() emits the same envelope with
  items: [] for hardware even when cards are maintained, so the cards
  stay on the product and reappear when switching back to layout 1 or 2
- Spec v1.27 (resources.sectionLayout values)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:11:26 +01:00

100 KiB
Executable File
Raw Blame History

VITEC Headless JSON Architecture — Software Architecture and Interface Specification

Document identifier EVO‑VITEC‑HL‑001
Version 1.26
Status Released
Date 2026‑10‑26
Applies to evomedien/vitec on TYPO3 v14.3 (headless)
Owner evomedien — VITEC relaunch

Revision history

Version Date Changes
1.0 2026‑07‑09 Initial released specification.
1.1 2026‑08‑05 Catalogue completed with the card, customer‑logo, location and form plugins (7.8–7.11, 8). Page‑level auxiliary fields — menus and favicons — specified (6.7, 7.12). Form‑submission endpoint added to the interface (7.8.2). Crop‑variant and debug‑flag conventions added (9.9, 9.10). News CType count corrected to nine and the =< marking on news_pi1 withdrawn (7.6, 8, B‑1). Nonconformities B‑8…B‑10 recorded.
1.2 2026‑08‑05 Interface change (additive): tx_vitec_domain_model_market and tx_vitec_domain_model_solution gained a slug field, now emitted by MarketShowJsonRenderer, SolutionShowJsonRenderer and the card payload. Market and Solution detail payloads specified (7.13); 7.9 updated.
1.3 2026‑08‑05 New plugin vitec_marketlist (MarketListJsonRenderer, payload key markets) — first list plugin built entirely on the shared serializer per 9.2, with editor‑controlled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated.
1.4 2026‑08‑05 Interface change (additive): tx_vitec_domain_model_market gained a detail_page field (TCA group/pages), emitted as the resolved detailUrl in both market payloads (7.13.1, 7.13.2). Not added to Solution — see B‑11.
1.5 2026‑08‑05 Defect fix, output‑changing: richtext fields were emitted as raw database content by every VITEC UserFunc renderer, leaving t3:// links unresolved in the JSON. New RteResolver service and mandatory convention 9.11; applied at all 19 richtext call sites across 11 renderers. Duplication register 10.2 updated.
1.6 2026‑08‑06 Interface change (additive): tx_vitec_domain_model_download gained a second category field type (display taxonomy; MM rows distinguished by fieldname), emitted as the string type in the downloadcard, downloadcardcollection and datasheets payloads — analogous to filetype. New CLI command vitec:import-downloads migrates the old‑site downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate file_urls merged).
1.7 2026‑08‑06 Robustness: the import normalizes legacy filenames on fetch so every imported file matches the version convention (__NN_A → __NN-A, ___NN → __NN, __NNA → __NN-A, bare __NN → __NN-A as initial revision), and the three download renderers gained a filepath fallback in getDownloadFile() (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver service (10.2).
1.8 2026‑08‑13 Defect fix and interface change (additive). Content Blocks never carried the Core Appearance tab: layout, frame_class — including the VITEC frame classes — space_before_class, space_after_class, sectionIndex and linkToTop were unreachable for editors on all nine blocks. Added centrally for every vitec_* type (7.7); the appearance envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside data on top‑level blocks (B‑13), and appearance.layout is represented differently on the two envelope paths (B‑14). intro-paragraph gained background_color (7.7). vitec_eventlist gained the layout regions, emitting a regions array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved.
1.9 2026‑08‑15 Interface change, partly breaking. The four list plugins were unified: every one of them now emits an object carrying layout, the new showToolbar flag and its payload array. vitec_usecaselist and vitec_productlist previously emitted a bare array — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin vitec_solutionlist (SolutionListJsonRenderer, key solutions), the counterpart 7.13.2 had been asking for since v1.2. tx_vitec_domain_model_usecase gained detail_page, emitted as the resolved detailUrl in the story card shape and therefore also in the vitec_modelcard story branch, which had carried no link at all until now. Noted: vitec_productlist had a configurable layout that was never serialised, and its vocabulary (0–3) differs from the other lists.
1.10 2026‑08‑18 Interface change (additive). tx_vitec_domain_model_product gained five fields: heroimage (multiple FAL images, detail payload only), the richtext fields description2, capabilities and textrelatedproducts, and portfolio (TCA link) — emitted as a resolved URL through the new LinkResolver::typolinkUrl(). The product payloads are specified for the first time (7.15). Links in contentelement / contentelementcta that point at a container now resolve its children (items, page‑level shape) and background (7.4). Two record link handlers (download, product) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware vitec/download-file streams /download/file/<uid> as a forced download (5.3), file lookup consolidated into DownloadFileResolver — first step towards the B‑4 target (10.2). Backend‑only: relatedprodukt moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8.
1.11 2026‑08‑21 New interface: site search. Apache Solr 10 (dedicated VPS behind an HTTPS reverse proxy) with apache-solr-for-typo3/solr 14.0.0-RC1. The EXT:solr results plugin solr_pi_results on the search page is rendered headless by SearchJsonRenderer (payload key search) - request/response contract in the new Clause 7.16. Indexed corpus: pages plus product, market (detail_page only), use-case, news and download records; result type vocabulary page|product|market|story|news|download. Downloads gained the canonical route /download/<slug> (uid route kept for the record links) and private_download is now enforced by DownloadFileResolver (5.3, 9.12). The VITEC Set now declares the solr set as a dependency - overriding a foreign set’s TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10.
1.12 2026‑08‑24 Interface change (additive). The search endpoint (7.16) gained a type filter and facet counts: request parameter filter (a value from the type vocabulary; the natural name type is unavailable - it is TYPO3’s reserved page-type parameter), response keys filter (active filter or null) and facets.type (per-type document counts with active flags; counts stay complete while a filter is active, except when the filtered result is empty). Editorial baseline of 76 managed synonyms imported into core_en (codecs, acquired-brand names such as exterity => avedia, UK/US spellings, common misspellings) - synonyms apply at query time, no re-index. New autocomplete endpoint: the EXT:solr suggest plugin as lean JSON page type 7384, deliberately USER_INT (7.16).
1.14 2026‑09‑11 Interface change (additive). Secondary search facets: stories index their market titles (market_stringM, MM relation), products their sys_category titles (category_stringM); the search endpoint accepts market and category GET parameters (values verbatim from facets.<name>[].value; an unknown value simply yields zero results) and echoes them as activeFacets. Visibility fix: the usecases queue now honours no_index/hideonwebsite, products defensively hideonwebsite - and the excluded story’s document was removed explicitly, because re-indexing never deletes.
1.15 2026‑09‑11 Corpus change (additive, index side). Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route /tika/*, own basic-auth credential) extracts PDF/Office contents during indexing (TikaDownloadContentIndexer on BeforeDocumentIsProcessedForIndexingEvent, fail-soft per 9.5, cached in var/tika-cache keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change.
1.16 2026‑09‑11 New plugin vitec_productfinder (ProductFinderJsonRenderer, payload key productfinder): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to vitec_productlist — filtering happens client side over the products already delivered, keyed on the category uids this payload carries.
1.17 2026‑09‑11 Interface change (additive). The product list payload gained successStories (7.15.2.1): stories an editor attaches to a sys_category (tab Options, with its own layout), emitted in the list envelope beside products. Each card carries forCategories — the product categories it came from — so the front end can hide it with the same ?cat=/?subcat= filter it applies to the products. Story links reuse the 7.5 rule, with the detail page taken from the element or the new site setting vitec.storyDetailPid.
1.18 2026‑09‑24 Interface change (additive). Products gained solutions — an MM relation to solutions, emitted as solution cards in both product payloads — and a Resources tab: child table tx_vitec_product_resource with documentation / support cards (editable title, text and icon around one link, resolved per 9.12; cards without a resolvable link are not emitted) plus a datasheets MM selection of download records rendered into the same envelope as type: datasheet cards, delivered detail‑only as the resources list envelope whose layout switches to carousel above three cards in total (7.15). The solutions relation accepts group‑level records: the eight groups exist as is_group = 1 solutions (created by vitec:create-solution-groups, excluded from lists and from vitec:align-solutions) because the agency workbooks reference the group level. Editorial: the footer had been stuck at v1.15 since 1.16/1.17 (2026‑09‑11).
1.19 2026‑09‑25 Interface change (additive). The vitec_card Content Block gained the layout_variant value flipcard and a Card Back palette: back_headline (string, max 100) and back_text (richtext → HTML). The two fields are displayCond‑gated on the flipcard layout in the backend but serialised unconditionally (7.7) — the front end shall key the back face on layout_variant, never on field presence, because stored back values persist when an editor switches the layout away. Backend repair: vitec_layout_variant / vitec_background_variant are shared with vitec_ctabanner, and the index‑wise Content‑Blocks merge had dropped four card‑only select values (“Missing label”) — per‑type item lists now come from columnsOverrides (7.7); the Card‑Back palette lives in the Layout tab below the variant select. Editorial: the head‑matter version had been stuck at v1.17 since 1.18.
1.20 2026‑10‑01 Interface change (additive). Sub-market page template: frontend layout 21 with one colPos group per design section (colPos110..colPos160, clause 7.17); new Content Blocks vitec_markethero, vitec_marketintro, vitec_marketinsights; new plugin vitec_productfeature (key productfeature, one product in the 7.15.2 item shape); story cards gained quote/quotePerson/quoteRole; vitec_usecaselist gained the layout value testimonials; vitec_productlist gained hand-picked product selection (editor order, category filter ignored). Field semantics: Template_Sub-Market_Spezifikation_2026-10-01.md.
1.21 2026‑10‑07 Interface change (additive) and corpus change. Search results carry image (processed 640 px tile image, root-relative, null for types without one). Corpus: story documents now include their body (the inline tt_content children), page and story content include the text columns of the market/solution Content Blocks and the vitec_items/vitec_faq_items collection children; solutions are indexed. News rank by publication date (datetime; crdate is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the /_json prefix the URL swap introduced, the type vocabulary lists solution (delivered since 1.14), and the footer had been stuck at v1.19.
1.22 2026‑10‑09 Interface change (additive) and defect fix. Search cards ("best bets", 7.16): editorial tx_vitec_domain_model_searchcard records with keyword lists; the search payload gained cards (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without q returned an empty body instead of the documented empty shape (stdClass resolved inside the renderer namespace).
1.23 2026‑10‑09 New interface (additive). Product type-ahead for the megamenu catalog panel: lean page type ?type=7385&q= (7.16) returning suggestions and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout catalog, filled by the VITEC listener ProvideProductCatalog (main categories → subcategory groups → products with one-line teasers).
1.24 2026‑10‑23 Interface change (additive). New frontend layout 22 Solution Group Page for the eight solution group hero pages (zones hero = colPos1, main = colPos0). Element order: vitec_markethero + vitec_introparagraph in the hero zone; vitec_marketintro 2, vitec_introparagraph + 4 text_only cards (challenges), vitec_introparagraph + 4 alternating horizontal/horizontal_reverse cards (capabilities, bullets as <ul>), vitec_usecaselist (testimonials, shipped hidden until stories are picked), vitec_marketinsights, vitec_ctabanner in main — imported from the agency Template‑06 workbooks by vitec:import-solution-group-workbooks; page properties as on the sub‑solution pages.
1.25 2026‑10‑23 Interface change (additive). The markets branch is aligned with the agreed agency structure: /markets eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout 23 Market Hero Page (zones hero = colPos1, main = colPos0): vitec_markethero (CTA anchors #submarkets) + vitec_introparagraph (header carries the hero H1) in the hero zone; vitec_marketintro, vitec_introparagraph, sub‑market vitec_cards whose primary_cta links the child page, vitec_ctabanner in main (vitec_customerlogos and vitec_usecaselist ship hidden until filled editorially). Imported by vitec:import-market-hero-workbooks; the tree itself by vitec:align-market-pages (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set.
1.26 2026‑10‑26 Interface change (additive). Product detail resources band simplified (customer CR): the resources envelope gains sectionLayout — 50-50 (default) | 33-33-33 — from the new per‑product select Resources Section Layout (Layout 1: Technical Highlights left, resource cards right, 50/50; Layout 2: highlights over two columns, cards in the third). Capabilities leave the band editorially; the TCA fields capabilities, datasheets and downloads are parked in the Misc tab — all three stay in the payload unchanged. The automatic newest‑datasheet/documentation cards remain deferred; meanwhile the manual card restype vocabulary gains datasheet and brochure — a pinned datasheet card links the download record and stays current because the download route always serves the newest file.
1.27 2026‑11‑02 Interface change (additive). Product Resources Section Layout gained the value hardware (“Layout 3 - Hardware option”): resources.sectionLayout = "hardware" — Technical Highlights over two columns, no resource cards (resources.items is emitted empty even when cards are maintained, so they reappear on a switch back).

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, cards, customer logos, locations, forms) 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.

Alongside content[] the page object carries page‑level auxiliary fields — the navigation menus, the favicon set and the schema.org graph (Clause 6.7). One write‑side endpoint complements the read interface: form submissions are POSTed back to the CMS (Clause 7.8.2).


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, page‑level auxiliary fields and the form‑submission endpoint (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
apache-solr-for-typo3/solr 14.0.0-RC1 Site search: indexing pipeline, query API
Apache Solr 10.0.0 (dedicated VPS) Search server, reached via an HTTPS reverse proxy
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. The same applies to apache-solr-for-typo3/solr, pinned to 14.0.0-RC1 (the only line for TYPO3 14).

5.2 Site configuration

The headless mode is activated in config/sites/vitec/config.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; it declares apache-solr-for-typo3/solr as a set dependency so the solr defaults load before the VITEC overrides - sets load in dependency order, and a set loaded later silently wins. 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 Frontend middlewares

Three middlewares are registered in Configuration/RequestMiddlewares.php, all after typo3/cms-core/normalized-params-attribute and before typo3/cms-frontend/site — that is, before page resolution:

Middleware Purpose
vitec/form-submission Answers POST /api/vitec/form/<formKey> (Clause 7.8.2). Every other request passes through untouched.
vitec/success-story-path-rewrite Rewrites /success-stories/<slug> internally to /success-stories/story/<slug> when <slug> matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest page‑slug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a non‑matching slug leaves the request untouched; any exception leaves the request untouched.
vitec/download-file Answers GET /download/<slug> (canonical; used by search results) and GET /download/file/<uid> (legacy; the download record links build this form): resolves the download record’s file (DownloadFileResolver: FAL → Collateral naming convention → filepath) and streams it with Content-Disposition: attachment (Clause 9.12). Records flagged private_download are refused. Unknown uid/slug, hidden or private record or missing file fall through to normal page resolution.

5.4 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
      ▼
[L7] Page‑level fields        menus (MenuProcessor) + favicons

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:

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>:

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:

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).

6.7 L7 — Page‑level auxiliary fields

Beyond content[] and jsonLd, four further fields are attached to the page object in Configuration/TypoScript/Headless/vitec_menus.typoscript:

page.10.fields.mainNavigation =< lib.mainNavigation
page.10.fields.footerMenu     =< lib.footerMenu
page.10.fields.metaMenu       =< lib.metaMenu
page.10.fields.favicons        = USER
page.10.fields.favicons.userFunc = Evomedien\Vitec\UserFunc\FaviconsJsonRenderer->render

The three menus are built by FriendsOfTYPO3\Headless\DataProcessing\MenuProcessor, which resolves shortcut pages to their target, skips pages with nav_hide = 1, marks active/current items and nests sub‑levels under children. mainNavigation is the full hierarchy (levels = 10, expandAll = 1); footerMenu and metaMenu are curated flat lists driven by the site settings menu.footer.pageUids and menu.meta.pageUids. All three use the title field nav_title // title and exclude spacers.

Note

These menus use the reference operator =< against lib.* objects. That is the idiomatic headless form and is safe; the <‑copy rule of 6.4/10.5(1) constrains tt_content → tt_content derivations only.

Payloads are specified in Clause 7.12.


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:

{
  "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:

{ "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, markets, solutions, news → items) object (see below)
Detail plugin singular noun (product, usecase, market, solution) object
Container items array of {config, contentElements}

List envelope (since v1.9). The four record list plugins — vitec_productlist, vitec_usecaselist, vitec_marketlist, vitec_solutionlist — share one shape:

{ "layout": "grid",        // display variant, see the note below
  "showToolbar": false,    // render the toolbar above the list
  "<plural>": [ … ] }      // products | usecases | markets | solutions

vitec_usecaselist and vitec_productlist emitted a bare array before v1.9; a consumer has to read <key>.<key> now. layout uses grid | list | carousel | 50-50 everywhere except vitec_productlist, which keeps its own 0–3 ("Default", "Variation 1–3") — unifying it would move existing content onto a different layout and needs a migration, so it was left alone.

news_* is not part of this: it keeps its own {mode, items|news, settings} envelope (7.6).

7.4 Container payload

{
  "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).
  • tx_vitec_bg_variant is passed through unchanged (background variant of the grid).
  • vitec_container and vitec_cards_carousel drop gap (single column / single track) and add cssClass, read from the FlexForm settings.cssClass.

vitec_cards_carousel additionally emits a carousel object from its FlexForm:

{ "carousel": { "slidesPerView": "3", "showArrows": "1", "showIndicators": "1",
                "loop": "0", "autoplay": "1", "autoplayInterval": "5000" } }

Note

These members are TypoScript TEXT values and therefore arrive as strings, not numbers or booleans. The front end coerces them.

The colPos value of each column is fixed by COLPOS_TO_COLUMN in ContainerChildrenProcessor — 211/212 (50/50), 221–223 (33/33/33), 231–234 (25/25/25/25), 241/242 (66/33), 251/252 (33/66) — and shall be kept in sync with the container TCA (Annex B‑5).

Linked containers (since v1.10). When a link resolved through ContentElementResolver (e.g. contentelement / contentelementcta, 7.15) points at a container, the resolver attaches the container's children under items in exactly the column shape above, plus the resolved background. Nested containers recurse (depth‑capped at 5, cycle‑safe); children are stripped of container‑level fields as in the page‑level rendering; nested plugins resolve per 9.7.

7.5 Success Story (use case) detail payload

Produced by UsecaseSerializer::serializeDetail() — the reference implementation of the centralised pattern (Clause 9.2):

{
  "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:

{ "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, … } }

Nine News CTypes share this one renderer — news_pi1 plus the eight variants news_newsliststicky, news_newsselectedlist, news_newsdetail, news_newsdatemenu, news_categorylist, news_newssearchform, news_newssearchresult, news_taglist. news_newsdetail yields mode: "detail" with a single news object. news_pi1 is derived with the < copy operator from lib.contentElementWithHeader, the eight variants with < from tt_content.news_pi1.

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.

Field naming. Fields carry the vitec_ vendor prefix in storage; the JSON key is the plain YAML identifier. ArrayRecursiveToArray decorates every key with TcaFieldDefinition->identifier, so the column vitec_background_color is emitted as background_color. A front end shall key on the identifier, never on the column name.

Field types are whitelisted. ArrayRecursiveToArray::processStringField() switches over the Content Blocks field types and drops anything that reaches its default branch. A new field type therefore shall be verified against the live JSON before it is relied upon; deriving it from the block definition alone is insufficient.

Appearance (since v1.8). Content Blocks builds its own showitem (TcaGenerator::getContentElementStandardShowItem) and appends only the Extended tab, so the Core Appearance tab was absent from every block. Configuration/TCA/Overrides/tt_content.php now appends --div--;core.form.tabs:appearance together with the Core palettes frames and appearanceLinks to every vitec_* type whose showitem lacks --palette--;;frames, positioned ahead of the Extended tab. The loop is idempotent, skips the Extbase plugins (which inherit the tab from tt_content types['header']) and covers future blocks automatically.

Redefining those palettes inside a block's config.yaml is prohibited: TCA palettes are global per identifier, so a second definition of frames collides with the Core one — the failure mode is a page module that throws RecordPropertyNotFoundException at runtime for existing elements, not a build error.

sectionIndex and linkToTop are not part of the appearance envelope — lib.appearance carries layout, frameClass, spaceBefore and spaceAfter only — and therefore do not reach the JSON. sectionIndex still governs the Core section‑menu element; linkToTop is inert in headless operation.

Colour fields. intro-paragraph carries background_color (type: Color): lower‑case hex, empty string when unset, with the VITEC palette offered as valuePicker presets. Hex is stored lower case deliberately, because the native colour picker emits lower case and an upper‑case preset would store one colour under two spellings. A block can therefore receive a background by two mechanisms — this field and the vitec-bg-* frame classes. The precedence between them is a front‑end decision and is deliberately not fixed here.

Card flip layout (since v1.19). vitec_card carries the additional layout_variant value flipcard together with a Card Back palette: back_headline (string, max 100) and back_text (richtext → HTML per 9.11). Both fields are serialised by the auto‑serialiser on every card — empty strings when unset — so a front end shall render the back face only when layout_variant equals flipcard, never keyed on the mere presence of the fields: an editor may switch a filled flip card to another layout, and the stored back values persist. In the backend the two fields are gated by displayCond on the flipcard layout (the select reloads the form via onChange); the palette sits in the Layout tab directly below layout_variant.

Beware: layout_variant and background_variant are columns shared with vitec_ctabanner (same identifier under the vendor prefix). Content Blocks merges diverging item lists of such a shared select index‑wise into the base column — four card‑only values simply did not exist as items (“[ Missing label ]” in the backend). Since v1.19 Configuration/TCA/Overrides/tt_content.php pins the base column to the shorter cta‑banner lists and hands vitec_card its full lists through columnsOverrides, honoured by FormEngine and DataHandler. Because that per‑type merge is array_replace_recursive, an items override shall be at least as long as the base list — and a new block shall not reuse these identifiers with diverging items; prefix instead, as hero_layout_variant / intro_layout_variant already do.

7.8 Forms

7.8.1 Form plugin payload

Produced by FormsJsonRenderer under content.form for all three form CTypes (vitec_contactform, vitec_demoform, vitec_helpdeskform); the CType selects the definition through FormDefinitions::CTYPE_MAP:

{ "formKey": "contact",                        // contact | demo | helpdesk
  "title": "Contact VITEC",
  "endpoint": "/api/vitec/form/contact",
  "honeypot": "_website",
  "fields": [
    { "name": "firstName", "type": "text",   "label": "First Name", "required": true },
    { "name": "email",     "type": "email",  "label": "Email",      "required": true },
    { "name": "country",   "type": "select", "label": "Country",    "required": false,
      "optionsSource": "countries" },
    { "name": "solution",  "type": "select", "label": "Solution of Interest",
      "required": false, "options": [ "IPTV Distribution", "…" ] }
  ] }

Field type is one of text, email, tel, select, textarea. A select carries either an inline options array or an optionsSource key (countries, usStates) naming a list the front end supplies itself — this keeps long ISO lists out of every page response and consistent across the app. contact and demo share one field set; helpdesk has its own, adding product and serialNumber.

Classes/Forms/FormDefinitions.php is the single source of truth: the same definition produces this JSON and validates the submission server‑side. Fields shall be added there and nowhere else.

7.8.2 Submission endpoint (write side)

FormSubmissionMiddleware (5.3) answers:

POST /api/vitec/form/<formKey>        Content-Type: application/json

Processing order: honeypot check → validation → persistence → delivery.

Situation HTTP Body
Accepted — including honeypot tripped and delivery failure 200 {"success": true}
Validation failed 422 {"success": false, "errors": {"<field>": "<message>"}}
Unknown formKey 404 {"success": false, "errors": {"_form": "Unknown form"}}
Method not POST 405 {"success": false, "errors": {"_form": "POST only"}}
Unexpected error 500 {"success": false, "errors": {"_form": "Unexpected error"}}

Every accepted submission is written to tx_vitec_form_submission (form_key, payload as JSON, delivery_method, delivery_status ∈ {pending, sent, failed}, delivery_error) before delivery is attempted. Delivery therefore cannot lose data: a failed delivery still answers success: true and the failure is visible on the backend record. Unknown payload keys are dropped by FormDefinitions::filterPayload(); a filled honeypot field (_website) is answered with success: true and stored nowhere.

Delivery is a strategy (Classes/Forms/Delivery/DeliveryInterface), selected by the FlexForm setting delivery (default email):

Strategy State
EmailDelivery active
SalesforceDelivery prepared stub — deliver() always throws. A submission with delivery = salesforce is stored and then marked delivery_status = failed; the endpoint still answers success: true, so no data is lost. The previous website posted to Salesforce Web‑to‑Lead; completing it requires the credentials plus the camelCase → Salesforce field‑id mapping.

NOTE (design constraint) Delivery settings are read from the first non‑deleted, non‑hidden plugin element of that CType found site‑wide — the endpoint is stateless and receives no element uid. There is therefore one delivery configuration per form type, not per placed element (Annex B‑10).

7.9 Card payload (vitec_modelcard)

One plugin serves four model types. The FlexForm picks modelType plus one record, and all card data comes from that record — nothing is authored on the element:

{ "modelType": "product",           // product | story | market | solution
  "layout": "vertical",
  "item": { "uid": 12, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
            "image": { "url": "…", "srcset": [ … ] } } }

item is null when no record is selected or the record is hidden/deleted. Per model type: story reuses UsecaseSerializer::serializeListItem() verbatim (the canonical card serialisation); product falls back from the image field to productimage; market and solution share one field set and add description. All four model types carry slug (since v1.2), so the front end can build a detail link from any card without a second request. Image resolution is delegated to UsecaseSerializer::image(). This renderer is the reference for reusing a serializer instead of re‑implementing FAL logic (9.2).

Model table names are held in the class constant MODEL_TABLES (10.3).

7.10 Locations payload (vitec_locationlist)

Produced by LocationsJsonRenderer under content.locations. The plugin is global: every visible tx_vitec_domain_model_location record is emitted, ordered by sorting; the page carrying the plugin is not used as a filter.

{ "variant": "grid",                // grid | list | map
  "mapText": "<p>…</p>",            // only when variant = "map" and text is set, else null
  "locations": [
    { "id": 3, "slug": "…", "name": "…", "countryCode": "DE",
      "coordinates": { "latitude": 50.1, "longitude": 8.6 },
      "address": { "company": …, "street": …, "additional": …,
                   "postalCode": …, "city": …, "region": …, "country": … },
      "contact": { "phone": …, "fax": …, "email": … },
      "links":   { "contact": "/contact", "legal": [ "/imprint", "…" ] },
      "marker":  { "label": "…", "color": "#ff6633", "size": 0.5 },
      "sorting": 1, "active": true } ] }

Empty address and contact members are null, never "", so the front end can test presence directly. links.contact is a resolved typolink; links.legal is a newline‑separated list of typolinks, each resolved individually. marker.label falls back to the location name, marker.color to #ff6633, marker.size to 0.5.

7.11 Customer‑logo payload (vitec_customerlogos)

Produced by CustomerlogosJsonRenderer under content.customerlogos:

{ "layout": "grid",                 // list | grid | carousel | marquee
  "logos": [ { "id": 7, "name": "…", "emphasized": false, "color": true,
               "logo": { "uid": 42, "url": "…", "title": "…", "alternative": "…",
                         "srcset": [ … ], "properties": { "mimeType": "…" } } } ] }

Selection semantics — normative for the front end:

FlexForm state Result
no customers selected all logos, each color: false (render black‑and‑white)
customers selected the selected ones first, in selection order, color: true; then all remaining logos, color: false
customers selected + onlySelected only the selected ones, color: true

color is therefore a rendering hint, not a property of the record. SVG logos are delivered unprocessed with an empty srcset; raster logos receive a WebP srcset.

7.12 Page‑level auxiliary payloads

Attached to the page object, not to a content element (Clause 6.7).

mainNavigation / footerMenu / metaMenu — arrays of headless MenuProcessor items (title, link, active, current, spacer, children[]).

favicons — a static, page‑independent descriptor set produced by FaviconsJsonRenderer, so the head tags are CMS‑driven instead of hard‑coded in React:

{ "themeColor": "#26358C",
  "manifest": "/fileadmin/icons/site.webmanifest",
  "links": [ { "rel": "icon", "type": "image/x-icon",
               "href": "/fileadmin/icons/favicon.ico" },
             { "rel": "icon", "type": "image/png", "sizes": "32x32", "href": "…" },
             { "rel": "icon", "type": "image/png", "sizes": "16x16", "href": "…" },
             { "rel": "apple-touch-icon", "sizes": "180x180", "href": "…" },
             { "rel": "manifest", "href": "…" } ] }

links is ordered most‑ to least‑specific and is meant to be rendered verbatim as <link> elements. Hrefs are root‑relative under /fileadmin/icons/ — the same convention as image URLs (UsecaseSerializer::image()). If the front end is ever served from an origin other than the CMS, it must prefix these hrefs with the CMS base URL, exactly as it does for image URLs.

7.13 Market and Solution payloads

7.13.1 Detail payloads

MarketShowJsonRenderer and SolutionShowJsonRenderer emit the same shape under content.market and content.solution respectively — the two models are field‑for‑field identical:

{ "uid": 4, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
  "description": "<p>…</p>",                       // RTE HTML
  "detailUrl": "/markets/aviation/",               // market only, null when unset
  "categories": [ { "uid": 9, "title": "…", "description": "…" } ],
  "image": { "uid": 12, "url": "…", "title": "…", "alternative": "…",
             "description": "…", "srcset": [ … ],
             "properties": { "width": 1920, "height": 1080, … } } }

slug was added in v1.2. It is generated from title with eval: uniqueInPid, exactly as on product and usecase (9.9 governs their images, 9.6 their naming), and is the routing handle for the /markets/<slug>/ and /solutions/<slug>/ URLs of the relaunch sitemap.

detailUrl was added in v1.4 and exists on Market only. It is backed by the TCA field detail_page (type: group, allowed: pages, maxitems: 1) — the editor picks the page with the standard page browser. The renderer shall not expose the raw page uid: a headless front end cannot turn a uid into a link, so the value is resolved with typoLink_URL() server side, the same convention as headerLink (6.2) and the location links (7.10). It is null when no page is selected or the link cannot be resolved — never 0 and never an empty string, so the front end can test it directly.

Note

Neither model is routed by slug yet: market and solution pages are still resolved as ordinary TYPO3 pages carrying a …show plugin that selects one record via its FlexForm. The field exists so the front end can build canonical URLs today, and so slug‑based routing (a route enhancer, as product already has, or a middleware, as Success Stories have) can be introduced later without a second data migration.

7.13.2 Market list payload (vitec_marketlist)

MarketListJsonRenderer emits, under content.markets:

{ "layout": "grid",                 // grid | list | carousel
  "markets": [
    { "uid": 4, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
      "description": "<p>…</p>",           // RTE HTML, resolved per 9.11
      "detailUrl": "/markets/aviation/",   // null when no detail page is set
      "image": { "url": "…", "srcset": [ … ] } } ] }

The page carrying the plugin is never used as a filter. Which markets appear, and in which order, is decided by the FlexForm field settings.markets:

FlexForm state Result
nothing selected all visible markets, alphabetical by title
markets selected exactly those, in the order the editor arranged them in the FlexForm

The second row is the load‑bearing one: settings.markets stores a comma‑separated uid list whose sequence is the intended display order. A WHERE uid IN (…) query returns rows in storage order and would silently discard it, so the renderer fetches the (small) table once and rebuilds the sequence in PHP — the same approach CustomerlogosJsonRenderer uses. Fetching everything also means a selected record that has since been hidden or deleted simply drops out instead of producing a gap or an error.

tx_vitec_domain_model_market has no sorting column, which is why the unselected case falls back to alphabetical rather than to a backend‑defined order.

Each item carries the same field set as the detail payload (7.13.1) apart from categories, so the front end can render a list item, a card and a detail header from one shape. description is included and is RTE HTML resolved per 9.11 — it was omitted in v1.3 on the assumption that lists only need teaser, which turned out to be wrong in practice. Image resolution is delegated to UsecaseSerializer::image() per 9.2; this renderer duplicates no FAL logic.

7.13.3 Solution list payload (vitec_solutionlist)

NOTE (since v1.18) Group‑level solution records (is_group = 1, the eight thematic groups) never appear in this list. They exist to be selected in the product solutions relation (7.15) and carry the group page as detailUrl; vitec:align-solutions ignores them, vitec:create-solution-groups creates them idempotently. Added in v1.9, and deliberately the same shape as 7.13.2 — SolutionListJsonRenderer emits under content.solutions:

{ "layout": "grid", "showToolbar": false,
  "solutions": [
    { "uid": 7, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
      "description": "<p>…</p>",             // RTE HTML, resolved per 9.11
      "detailUrl": "/solutions/iptv/",       // from the record's detail_page; null when unset
      "image": { "url": "…", "srcset": [ … ] } } ] }

Selection and ordering follow 7.13.2 exactly: nothing selected means all visible solutions alphabetically, a selection means precisely those in the arranged order. tx_vitec_domain_model_solution has no sorting column either, which is why the unselected case falls back to alphabetical rather than to a backend‑defined order.

7.14 Event list payload (vitec_eventlist)

EventlistJsonRenderer emits, under content.eventlist:

{ "events": [
    { "uid": 1, "title": "…", "slug": "…", "teaser": "…",
      "description": "<p>…</p>",          // RTE HTML, resolved per 9.11
      "eventstart": "2027-07-09",         // ISO date, null when unset
      "eventend": "2027-07-09",
      "venue": "…", "booth": "…", "city": "…", "country": "…",
      "attendancemode": "offline",        // offline | online | mixed
      "eventstatus": "scheduled",
      "eventurl": "…", "meetinglink": "…",
      "image": { "url": "…", "srcset": [ … ] },
      "categories": [ { "uid": 107, "title": "…", "description": "…" } ] } ],
  "settings": { "layout": "list",         // list | grid | teaserbar | regions
                "showpast": false, "daysinadvance": 0, "limit": 0 },
  "pastEvents": [ … ],                    // only when showpast is set
  "regions":    [ … ] }                   // only when layout = regions

"Upcoming" means eventend >= today, or eventstart >= today when no end date is set; ordering is eventstart ascending. pastEvents inverts both and is present only when the FlexForm flag showpast is set.

7.14.1 Region payload (layout: regions)

For the regions layout the renderer additionally emits one entry per region — the sys_category records directly below the parent category 104 — that has at least one upcoming event:

{ "uid": 107, "title": "European Events",
  "description": "",                      // plain text; sys_category is not a richtext field
  "eventCount": 2,
  "image": { "url": "…", "srcset": [ … ] },   // the next event's image, i.e. the region logo
  "nextEvent": { "uid": 1, "title": "…", "slug": "…",
                 "eventstart": "2027-07-09", "eventend": "2027-07-09",
                 "eventurl": "…" } }

Normative behaviour:

  • A region without an upcoming event is omitted entirely. showpast does not reinstate it — the layout answers "what is coming up where", not "what happened".
  • An event filed under a descendant of a region counts for that region. The category branch is resolved in PHP from a single sys_category read, per 9.2's preference for one query over per‑level recursion.
  • Ordering is the backend sorting of the categories, so the carousel order is editor controlled. nextEvent.eventstart is included so a front end may sort chronologically instead.
  • daysinadvance applies. limit does not: it caps events, and applying it before the grouping would silently drop whole regions.
  • The parent category id is the constant REGION_PARENT_CATEGORY in the renderer, not a FlexForm setting — a hard‑coded literal in the sense of B‑5, accepted here because it belongs to the content model rather than to a single content element.

An event carrying no category belongs to no region and is invisible to this layout while remaining present in events.

7.15 Product payloads

7.15.1 Detail payload (vitec_productshow, key product)

Produced by ProductShowJsonRenderer::serializeProduct(). Members:

Member Type Notes
uid int
title, slug, urltitle, seotitle, seometa, keywords, structureddata, teaser, subtitle, video, shortcutpid string raw column values; video is a YouTube clip id
applications, capabilities, description, description2, highlights, textrelatedproducts string richtext, resolved per 9.11
hideonapp, hideonwebsite, hideondatasheets, hideonproducts, shortcut, legacy, supportproduct, subproduct, showdatapath bool
link string /product/<slug>
portfolio string | null resolved URL of the portfolio link (9.12); null = no link
contentelement, contentelementcta object | null linked tt_content element in the envelope shape of 7.2; a linked container carries items and background (7.4)
categories array {uid, title, description}
images array product images with url, srcset and properties (9.9)
heroimage array hero images, same shape as images — detail payload only
downloads array download records incl. the resolved file
ogimage, videofile object | null FAL singletons
relatedprodukt array card shape {uid, title, slug, subtitle, teaser, description, link, images}, backend order
solutions array solution card shape {uid, title, slug, subtitle, teaser, description, detailUrl, isGroup, image} via tx_vitec_product_solution_mm, backend order; an editor may select individual Generic Solutions or the eight group‑level records (isGroup: true, linking to the group page)
resources object list envelope {layout, items[]}: hand‑written cards (type documentation|support|datasheet|brochure, title, text, icon, resolved url per 9.12; a card without a resolvable link is dropped) followed by the datasheets MM selection of download records as type: datasheet cards (title/teaser/icon from the record, url = the public download route); layout is carousel above three cards in total; sectionLayout (50-50 default
jsonLd string ready‑to‑emit schema.org @graph (6.6), detail only

7.15.2 List payload (vitec_productlist, key products)

List envelope per 7.3 (layout, showToolbar, products[]) — since v1.9. Each item carries the member set of 7.15.1 except heroimage, resources and jsonLd. heroimage is deliberately detail‑only: one further FAL query per product would buy data a list rarely shows. The layout vocabulary remains 0–3 (see the v1.9 note).


7.15.2.1 Success stories below a product list

The list payload carries a second array beside products: the success stories an editor attached to the categories this element renders. The link lives on sys_category (tab Options), not on the plugin, so one decision serves every list that shows the category.

"successStories": {
  "layout": "grid",
  "stories": [
    {
      "uid": 12,
      "title": "Abu Dhabi Islamic Bank",
      "detailUrl": "/success-stories/abu-dhabi-islamic-bank",
      "cardImage": { },
      "categories": [ ],
      "forCategories": [58]
    }
  ]
}

Envelope per 7.3 (layout plus the array); layout uses the story-list vocabulary grid|list|carousel|50-50 and comes from the first category that contributes stories. Cards are the shared story shape of 7.5, so the same front-end component renders them.

forCategories is the addition: the product categories the story was attached to — not to be confused with categories, which stays the story's own topic categories. The front end filters on it, using the same ?cat= / ?subcat= parameters the product finder (7.15.3) writes: a card stays visible while one of its categories is selected. Stories are collected over the configured categories and their descendants, deduplicated, each carrying every category it came from. An element configured to show all products has no category context and returns the empty envelope.

detailUrl follows 7.5: the story's own detail_page wins; otherwise the URL is built from the detail page's parent path plus the story slug — the form SuccessStoryPathRewrite maps back. That page is taken from the element's FlexForm, falling back to the site setting vitec.storyDetailPid, and detailUrl is null when neither is set.


7.15.3 Product Finder payload (vitec_productfinder, key productfinder)

The filter above the product lists is fed by its own element. It carries the product category hierarchy only — no products, no counts:

{
  "type": "vitec_productfinder",
  "content": {
    "productfinder": {
      "categories": [
        {
          "uid": 58,
          "title": "Platforms and End-Points",
          "subcategories": [
            { "uid": 66, "title": "Avedia Platform" },
            { "uid": 67, "title": "EZ TV Platform" },
            { "uid": 68, "title": "APEX Platform" }
          ]
        }
      ]
    }
  }
}

categories[] are the children of the configured root category, subcategories[] their direct children — the same two levels the product records hang on, so a selected value maps straight onto product.categories[].uid in the list payload (7.15.2). The editor places one element above the lists; FlexForm offers the category root (empty = the Product tree) and the order of both levels (backend order, title, uid).

Filtering itself is a front-end concern: the list plugin is unchanged, and the front end narrows the products it already received using the URL parameters ?cat= and ?subcat=. Categories without products are emitted like any other — the front end decides whether to offer or suppress an empty result.


7.16 Search payload (solr_pi_results)

The site search runs on Apache Solr through EXT:solr. The EXT:solr results plugin solr_pi_results sits on the dedicated search page (/search) and is rendered headless by SearchJsonRenderer under the payload key search. The renderer runs EXT:solr’s own query pipeline (SearchRequestBuilder → SearchResultSetService) - only the rendering differs from the stock Fluid plugin.

Since the URL swap of 2026‑09‑28 every JSON URL carries the prefix /_json: the search answers at /_json/search?q=…, while the bare /search serves the single-page app. Paths in this clause are given without the prefix.

Request. GET parameters on the search page:

Parameter Meaning
q Search terms. Absent or empty: the response keeps its full shape with numFound: 0, so the front end never needs a second code path.
page 1-based result page, optional.
filter Restrict results to one type from the type vocabulary below (e.g. filter=product). Unknown values are ignored. Named filter because type is TYPO3’s reserved page-type parameter.
market Restrict to one market (stories carry the facet). Value verbatim from facets.market[].value; unknown values yield zero results.
category Restrict to one product category. Value verbatim from facets.category[].value.

The EXT:solr namespace (tx_solr[q], tx_solr[page]) is accepted as a fallback. All three parameters are excluded from cHash validation; the search page is never served from the page cache (the renderer disables caching per request - cached variants would collide because excluded parameters do not enter the cache identifier).

Response under content.search:

{
  "query": "encoder",
  "page": 1,
  "resultsPerPage": 10,
  "numFound": 202,
  "totalPages": 21,
  "filter": null,
  "activeFacets": {},
  "facets": {
    "market": [
      { "value": "Sports, Venues & Entertainment", "count": 9, "active": false }
    ],
    "type": [
      { "value": "news", "count": 137, "active": false },
      { "value": "product", "count": 13, "active": false }
    ]
  },
  "cards": [
    { "title": "Control Room Platforms", "teaser": "Unified, low-latency video …",
      "image": "/fileadmin/_processed_/…/csm_gov_16-9_….jpg",
      "url": "/solutions/control-room-command-centre-solutions/…",
      "linkLabel": "", "type": "solution", "keyword": "control room" }
  ],
  "results": [
    { "title": "MGW Diamond-H", "url": "/product/mgw-diamond-h-hdmi-encoder",
      "type": "product", "teaser": "… 4K HDMI <mark>Encoder</mark> …",
      "image": "/fileadmin/_processed_/…/csm_mgw-diamond-h_….jpg" }
  ],
  "suggestions": []
}
  • type shall be one of page | product | market | solution | story | news | download (mapping table in the renderer; unknown index types pass through verbatim).
  • teaser carries the highlighted fragment (<mark>…</mark>, fragments joined with " ... ") when highlighting applies, otherwise a plain 250-character excerpt of the indexed content. It is the only payload field containing markup.
  • url follows the shared link contract: root-relative, resolved server-side. download results point at the forced-download endpoint /download/<slug> (5.3) - front ends should present them as file downloads.
  • image is a processed tile image (640 px wide, root-relative) or null. Sources: products image (fallback productimage), stories card_image (fallback hero_bgimage), markets/solutions image, news fal_media; pages and downloads carry none.
  • facets.type carries the per-type counts for filter tabs, ordered by count. While a filter is active the counts remain those of the unfiltered query, so tabs do not collapse - except when the filtered result is empty, where only the active option (count 0) is returned; front ends should then offer "remove filter" rather than rely on the other counts.
  • filter echoes the active type filter, null when none; activeFacets echoes the active secondary facet filters as {name: value}, {} when none.
  • facets.market (story market titles, from the MM relation) and facets.category (product sys_category titles) appear whenever matching documents carry the fields; further facets are pure TypoScript.
  • suggestions lists spellcheck alternatives ("did you mean"), [] if none.
  • cards lists editorial search cards for the query (below), [] if none.

Search cards ("best bets", since v1.22). Editors maintain records of tx_vitec_domain_model_searchcard (any storage folder; hidden/start/end time apply). A card shall appear above the results when the query contains one of its keywords as a whole word or phrase - case- and punctuation-insensitive, including one level of the Solr managed synonyms in both directions. At most three cards, highest priority first, deduplicated by url; delivered on page 1 only (load-more pages carry []) and independent of filter, the secondary facets and numFound - a card may stand above zero results.

Field Meaning
title Card title.
teaser Plain text (no markup, unlike result teasers), may be empty.
image 640 px tile, root-relative, or null.
url Root-relative link target (or absolute for external links).
linkLabel Button text, "" = front-end default (e.g. "Learn more").
type Result type of the referenced record (product, story, …), custom for a free card.
keyword The maintained keyword that fired (diagnostics, tracking).

A card referencing a record takes title, teaser, image and URL from that record’s indexed Solr document, so it always matches the record’s own search result; filled card fields override. A card without a resolvable title or URL (e.g. the record is hidden or not indexed) is skipped silently.

Indexed corpus (plugin.tx_solr.index.queue): pages, plus records with a resolvable public URL - products, markets (only those with detail_page), use cases, news (type = 0; "page as news" records are excluded because their target pages are already indexed) and downloads (private_download = 0 AND hideonwebsite = 0; their content additionally carries the file text extracted through Apache Tika, so datasheet specifications are searchable). Solutions are indexed like markets (detail_page > 0). Stories include their body - the inline tt_content children - and both page and story content include the text columns of the market/solution Content Blocks and the collection children (vitec_items, vitec_faq_items). Ranking boosts products (^10) and stories (^2); news decay with age by their publication date (datetime mapped to created - crdate is the import date), all other types are exempt. Every executed search is logged to tx_solr_statistics with the last two IP octets masked.

Autocomplete. GET /_json/search?type=7384&tx_solr[queryString]=<input> answers as a lean JSON page type without the page envelope:

{ "suggestions": { "encoder": 118, "encoding": 171 },
  "suggestion": "enc", "documents": [], "didSecondSearch": false }

Term completions come from the index (spell field) with document counts; synonyms and all indexed types are reflected automatically. The plugin is wired as USER_INT - the example shipped with EXT:solr predates TYPO3 v14 (config.no_cache no longer exists), and a cached USER would pin the first query’s suggestions for every later request. Front ends should debounce (~200 ms), start at 2-3 characters, and turn a picked suggestion into a regular /_json/search?q= request. Top-result documents are deliberately disabled (showTopResults = 0): their shape is hardcoded upstream and carries the raw document type and the full indexed content.

Product type-ahead (megamenu, since v1.23). GET /_json/?type=7385&q=<input> answers as a lean JSON page type (ProductMenuSearchJsonRenderer, USER_INT):

{ "query": "mgw d",
  "suggestions": [ "MGW Diamond+ OG", "MGW Diamond" ],
  "products": [ { "title": "MGW Diamond+ OG", "teaser": "Broadcast-grade 4K HEVC …",
                  "image": "/fileadmin/_processed_/…", "url": "/product/…" } ] }

It runs through Solr restricted to product documents; the last term is completed as a prefix, so cards appear while typing (inputs below two characters yield the empty shape). At most six products, one card per product; suggestions are their titles for the autocomplete list. teaser is the product's plain-text one-liner from the record (≤ 120 characters), image the indexed 640 px tile or null. The endpoint and its parameters travel with the megamenu catalog payload (catalog.search); the catalog itself (layout: "catalog", categories → groups → products) is produced by the ProvideProductCatalog listener of EXT:evo_megamenu_json’s BuildCatalogEvent - see that extension’s README.

Operations. Indexing runs automatically: record and page saves enter the index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them to Solr; the CLI command vitec:solr-index (with --initialize and --debug) covers manual runs - EXT:solr 14 ships no console commands of its own. Versions: EXT:solr 14.0.2 and the matching image typo3solr/ext-solr:14.0.2 (Solr 10); an image upgrade needs a fresh volume, because an existing volume keeps its old configset - managed synonyms are restored through the REST API. Page content is extracted from the page’s tt_content rows via index.queue.pages.fields.content because the headless JSON output carries no TYPO3SEARCH markers.


7.17 Sub-market page template (frontend layout 21)

A sub-market page is identified by appearance.layout == "layout-21". Its content is NOT read from element order: the backend layout assigns one column per design section, and the JSON groups them as

Group Section Elements
colPos110 Hero vitec_markethero
colPos120 Intro vitec_marketintro
colPos130 Workflow vitec_introparagraph + container with vitec_card flip cards
colPos140 Testimonials vitec_usecaselist, layout: "testimonials"
colPos150 Products vitec_productfeature + vitec_productlist (hand-picked)
colPos160 Industry Insights vitec_marketinsights

The three vitec_market* elements are Content Blocks: their fields arrive unprefixed under the element's data (7.7). Field reference and empty-value semantics ("empty url = card hidden" etc.) are specified in Template_Sub-Market_Spezifikation_2026-10-01.md; the per-field limits live in the backend field descriptions.

Related interface additions (all additive):

  • Story cards (7.5 shape, everywhere they appear) carry quote, quotePerson, quoteRole - maintained once per story, empty strings until maintained. The testimonial card renders them; the vitec_usecaselist FlexForm gained the layout value testimonials for this.
  • vitec_productfeature (payload key productfeature): exactly one referenced product as a full product card in the 7.15.2 item shape, null when unset or the product is not visible. Serialisation is shared with the product list (one source of truth).
  • vitec_productlist gained the FlexForm field Hand-picked products: when set, the list contains exactly those products in the editor's order; the category selection is ignored, the visibility filters stay in force.

8 Content‑type catalogue

CType TS pattern Renderer / Processor Payload key Kind
vitec_productlist < lib.contentElementWithHeader ProductListJsonRenderer products list
vitec_productfinder < lib.contentElementWithHeader ProductFinderJsonRenderer productfinder taxonomy
vitec_productshow < lib.…WithHeader ProductShowJsonRenderer product detail
vitec_usecaselist < lib.…WithHeader UsecaseListJsonRenderer → UsecaseSerializer usecases list
vitec_usecaseshow < lib.…WithHeader UsecaseShowJsonRenderer → UsecaseSerializer usecase detail
vitec_marketlist < lib.…WithHeader MarketListJsonRenderer markets list (global)
vitec_solutionlist < lib.…WithHeader SolutionListJsonRenderer solutions list (global)
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 (7.14)
vitec_locationlist < lib.…WithHeader LocationsJsonRenderer locations list (global)
vitec_customerlogos < lib.…WithHeader CustomerlogosJsonRenderer customerlogos list
vitec_modelcard < lib.…WithHeader ModelcardJsonRenderer card detail (4 model types)
vitec_contactform < lib.…WithHeader FormsJsonRenderer form form
vitec_demoform < tt_content.vitec_contactform FormsJsonRenderer form form
vitec_helpdeskform < tt_content.vitec_contactform FormsJsonRenderer form form
news_pi1 (+8 variants) < lib.…WithHeader; variants < tt_content.news_pi1 NewsJsonRenderer news list/detail
solr_pi_results < lib.…WithHeader SearchJsonRenderer search search (7.16)
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 =< against a lib.* object; verified safe, see Annex B‑1. vitec_eventlist is the only remaining =< derivation among the content elements.

Page‑level fields (not content elements): jsonLd (PageJsonLdRenderer), favicons (FaviconsJsonRenderer) and mainNavigation / footerMenu / metaMenu (headless MenuProcessor) — Clauses 6.7 and 7.12.

Registered but not headless‑enabled: vitec_simplecard is registered as an Extbase plugin and offered in the content‑element wizard, but has neither a TypoScript mapping nor a renderer; placed on a page it emits a content element without a payload (Annex B‑8).


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:

$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.

9.9 Image crop variants

The "first image" of every card‑capable model (Product, Success Story, Market, Solution) shall declare its crop variants through the single definition Evomedien\Vitec\Tca\CropVariants::firstImage(), referenced from the model TCA:

'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),

It yields three editor tabs — default (free, 16:9, 4:3, 1:1; for detail, hero and list use), card (4:3) and largeCard (16:9); a free crop stays available in each. Crop variants shall not be redefined per model.

9.10 Debug flag

A plugin FlexForm may expose a settings.debug checkbox. When it is set the renderer shall attach a debug member to its payload (typically settings plus a count or the resolved record uid) and shall not change the regular payload in any other way. This is the only sanctioned form of diagnostic output (9.5); debug is absent from production payloads.

9.11 Richtext fields

Any field whose TCA carries enableRichtext shall be passed through Evomedien\Vitec\Service\RteResolver::html() before it enters a payload. Handing the raw database column to JSON is prohibited.

'description' => RteResolver::html($market['description'] ?? ''),   // correct
'description' => (string)($market['description'] ?? ''),            // prohibited

Rationale. TYPO3 stores richtext with unresolved internal references — internal links as <a href="t3://page?uid=12">, legacy content as <link> tags, images with relative paths. Fluid resolves these through parseFunc at render time. A headless renderer that skips that step ships dead links to the front end, and the defect is invisible until an editor actually places an internal link.

The upstream packages already do this for the output they own: friendsoftypo3/headless applies parseFunc =< lib.parseFunc_RTE to the core text elements via TypoScript, and nb-headless-content-blocks calls parseFunc($value, null, '< lib.parseFunc_RTE') for every Content Block field with enableRichtext. RteResolver performs the identical call, so all three paths produce the same HTML.

The resolver is static — the conversion is stateless and the call sites are payload array literals — and fail‑soft per 9.5: when no TypoScript setup is available it returns the raw value rather than throwing, so content is never lost.

Note

enableRichtext shall be the boolean true, not the string 'true'. FormEngine accepts both, but nb-headless-content-blocks tests with === true and a string silently disables its richtext conversion. Three product fields carried the string form until v1.5.

Fields that merely look like richtext are out of scope: FAL metadata (sys_file_reference.description), sys_category.description and sys_file metadata are plain text and shall not be passed through the resolver.

JSON output shall never carry an unresolved TYPO3 link (t3://…). Every link field is resolved server‑side — LinkResolver::pageUrl() for page uids, LinkResolver::typolinkUrl() for full typolink parameters — under the shared contract null means "no link": a hidden or deleted target resolves to null, never to a broken URL.

The link browser offers two record link handlers (TCEMAIN.linkHandler, Configuration/page.tsconfig), stored as t3://record?identifier=<key>&uid=<n> and resolved by config.recordLinks in setup.typoscript:

Identifier Resolves to Notes
product /product/<slug> same convention as the link member (7.15); assumes the product detail page (uid 10) answers /product
download /download/file/<uid> forced‑download endpoint (5.3); the canonical public form is /download/<slug> - both routes share DownloadFileResolver, which refuses private_download records

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 — reused without duplication by ModelcardJsonRenderer (7.9), while FormDefinitions (7.8.1) is the equivalent single source of truth for the form plugins. Weakness: substantial duplication remains across the older inline renderers (Product, Download, Datasheet), which is why those are the largest files in the extension.

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, Customerlogos 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()
RTE parseFunc conversion 19 sites / 11 renderers RteResolver::html() — DONE (v1.5)

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. Registration — ExtensionUtility::configurePlugin() in ext_localconf.php, a FlexForm under Configuration/FlexForms/, an icon, and a wizard entry in Configuration/page.tsconfig.
  5. TypoScript — in Configuration/Sets/Vitecset/setup.typoscript: tt_content.<ctype> < lib.contentElementWithHeader and content.fields.<key> = USER + .userFunc = …->render. Omitting this step yields a content element with no payload (Annex B‑8).
  6. Header section — ensure the headers palette is present (9.8).
  7. Nesting — if the plugin may sit inside a container, register it in PLUGIN_RENDERERS in both the resolver and the processor (9.7).
  8. Deploy — vendor/bin/typo3 database:updateschema "*.add,*.change" then vendor/bin/typo3 cache:flush.
  9. Verify — fetch the page JSON; confirm envelope (C1), keys (C4) and nested output; confirm debug is absent unless the FlexForm flag is set (9.10).

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 the Event CType. vitec_eventlist uses the reference operator =< lib.contentElementWithHeader. VERIFIED SAFE and left as is: a reference to a lib.* object is idiomatic in headless and used by headless itself; only tt_content → tt_content references are unsafe, and those are < copies throughout (containers, News variants, form variants). The page‑level menus use =< against lib.* objects for the same reason (6.7). CORRECTION 2026‑08‑05: v1.0 of this document also listed news_pi1 here — news_pi1 is, and was, derived with <.
  • 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). OPEN. Both copies verified in sync on 2026‑08‑05 — 25 entries each (16 VITEC CTypes + 9 News CTypes). Remediation: promote to a single shared constant.
  • 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), the /fileadmin/icons prefix and the theme colour in FaviconsJsonRenderer, 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.
  • B‑8 — vitec_simplecard has no headless rendering. The plugin is registered in ext_localconf.php, has a FlexForm, Fluid templates and a wizard entry in Configuration/page.tsconfig, but there is no tt_content.vitec_simplecard mapping in setup.typoscript and no *JsonRenderer. Placed on a page it produces a content element without a payload. Decide: complete it per Annex A, or withdraw the plugin registration and the wizard entry.
  • B‑9 — Stray files in the extension. Four *.bak.rebuild files (UsecaseListJsonRenderer, UsecaseShowJsonRenderer, tx_vitec_domain_model_usecase, ext_tables.sql) and several tracked .msys00000… artefacts under Classes/DataProcessing/ and Classes/Service/. They are never loaded, but they are indexed by IDEs and static analysis. Remove.
  • B‑10 — One delivery configuration per form type. FormSubmissionMiddleware reads the delivery settings from the first matching plugin element found site‑wide (7.8.2). Per‑element configuration would require the front end to submit the element uid and the endpoint to resolve it. This is a documented constraint, not a defect — but placing two elements of the same form type with different delivery settings is silently ineffective.
  • B‑11 — detail_page exists on Market but not on Solution. Introduced in v1.4 on tx_vitec_domain_model_market only, because that is what was requested. The two models are otherwise field‑for‑field identical (7.13.1) and their renderers share one payload shape, so the asymmetry is a latent inconsistency: SolutionShowJsonRenderer emits no detailUrl at all. Either add the field to Solution as well, or record the divergence as intended. Related: ModelcardJsonRenderer serves market and solution from one shared branch and therefore emits no detailUrl either — the card payload (7.9) cannot link to a market detail page until this is resolved. Resolved 2026‑08‑10: detail_page added to Solution with the identical TCA shape and emitted by SolutionShowJsonRenderer; the shared market | solution branch of ModelcardJsonRenderer now serialises detailUrl, so market cards link as well.
  • B‑12 — detailUrl() duplicated in MarketListJsonRenderer and MarketShowJsonRenderer, and a third near‑identical resolveLink() lives in LocationsJsonRenderer (7.10). Three copies of "resolve a link server side" is the smallest concrete case of B‑4; it is the natural seed for the LinkResolver service that 10.2 calls for. Resolved 2026‑08‑10: Service\LinkResolver::pageUrl() is now the single implementation, with five callers. The contract is null = no link; the two Usecase renderers keep a thin wrapper appending ?? '' because they build paths by concatenation. LocationsJsonRenderer and NewsJsonRenderer stay outside it deliberately — the first resolves a whole parameter construct, the second builds slug paths plus a canonical, and neither is a page‑uid‑to‑URL mapping.
  • B‑13 — Appearance columns duplicated inside data. Since the Appearance tab was added (7.7), Record::toArray() resolves the six palette fields as part of the record, so top‑level Content Blocks emit layout, frame_class, space_before_class, space_after_class, sectionIndex and linkToTop raw inside data in addition to the processed appearance object. Container children are unaffected, being serialised by ContainerChildrenProcessor rather than RecordToArray. Harmless but redundant, and the raw and processed representations of layout differ (B‑14). Removable through a ModifyArrayRecursiveToArrayEvent listener.
  • B‑14 — appearance.layout is not represented uniformly. On the TypoScript path a CASE in lib.appearance maps the field to default / layout-1 / layout-2 / layout-3; container children serialised by ContainerChildrenProcessor carry the raw value ("0"). A front end consuming both paths must accept either form. Predates v1.8; recorded here because the Appearance tab makes the field editable in the first place.

  • 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.27.