Compare commits

..
64 Commits
Author SHA1 Message Date
orascheandClaude Fable 5.1 17b08df283 Content checks: flag placeholder images instead of counting them as done
Files below fileadmin/placeholders/ (or named "...placeholder...") are grey
dummies. PageCheckService and ProductCheckService now report them:

- image cells carry `placeholder` = number of placeholder files (only when > 0);
  `ok` is unchanged (something is attached)
- summary.filled counts only cells without placeholders, new summary.placeholder
  counts the cells that have one
- detail images carry `placeholder: true|false`
- cards: `placeholder` = cards whose icon/image is a placeholder; record: the
  record image

Additive change to the JSON contract; frontend rendering follows (ticket).
Requested by Harry 2026-11-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 18:47:12 +01:00
orascheandClaude Fable 5.1 20bcbeb731 Page content check for markets and solutions (vitec_pagecheck)
Sibling of the product check (decision Harry 2026-11-13): new content element vitec_pagecheck with a FlexForm scope (markets | solutions) and PageCheckService/PageCheckJsonRenderer. Overview = one row per page of the tree (11 markets + 24 sub-markets, 8 solution groups + 39 sub-solutions) with 18 checks taken from the page elements (hero image and subline, lead, intro text/image/CTA, cards with visuals, testimonials, products, insights text/cover/CTA, logos, CTA banner), the page properties (meta description, OG image, page media) and the market/solution record behind the page; columns a page type does not have come as ok null. ?check=<pageUid> delivers the page detail (elements in page order with texts, links, images, FlexForm settings; SEO, media, record, children). Always USER_INT, check is cHash-excluded already. New command vitec:pagecheck-pages created /markets/market-check (608) and /solutions/solution-check (609) from the product-check page properties; both live on DEV, rendering is front end work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 11:44:41 +01:00
orascheandClaude Fable 5.1 47c8e2902f Market pages: elements for the Figma structure (0124-FE)
New command vitec:markets-figma-elements (dry-run by default, --apply writes) for the 11 main market pages: numbered placeholder image placeholders/pages/<uid>-02.png and learnmore_target /contact on vitec_marketintro, the hidden Trusted-by and Case-studies shells of the 2026-10-23 page import unhidden (logos with the home page settings, case studies as testimonials with the empty selection = all stories, like the sub-market pages), the lead paragraph in the hero slot hidden (decision aehm 12.11., not deleted). Sub-market pages: vitec_marketinsights CTA /success-stories -> /insights/news with the label Explore our insights. Applied on DEV 2026-11-12: 11 images, 11 targets, 11 logo bands, 11 sliders, 11 hidden leads, 24 CTAs, 0 warnings; protocol in migrations/markets_figma_apply_20261112.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 19:14:05 +01:00
orascheandClaude Opus 5.5 6a00926b89 vitec:ssr-health --test-mail: send a sample alert through the real alert path
Deliverability check (e.g. mail-tester.com) with the same sender, mailer and plain-text format as a real alert. Optional address; without one the configured alert recipients get it. No health sample is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 15:39:53 +01:00
orascheandClaude Fable 5.1 4f47382679 Products menu: single-product overviews link to the product (spec v1.33)
Decision Harry 2026-11-12 for the third level of the products panel: an overview page with exactly one product is presented as that product. items[].link becomes the product detail page and items[].image the product picture (productimage, else heroimage, else the page media as before); new additive fields items[].linkType (product | overview) and items[].overviewLink (always the overview page). The product uid is resolved from the card link via the product slug; one extra query per build. Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway and ActiveSQX and switches back automatically once an editor adds a second card. Removing the sub-product line and the shortcut pill/subline is front end work (ticket 0121-BE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:11:56 +01:00
orascheandClaude Fable 5.1 8031247acf Page JSON without mainNavigation, i18n and favicons (0110-FE)
The frontend reads the main navigation from the headless initial-data type (?type=834, field navigation) in both SSR and SPA mode, has no language switch and keeps its favicons static, so the three page-level fields were dead weight (about 15 kB per page for mainNavigation alone). Removed from page.10.fields; lib.mainNavigation dropped, FaviconsJsonRenderer stays in the code base unused. Verified on DEV: page JSON of /, /product/aligo and /products/appliances without the three keys, footerMenu/metaMenu unchanged, /_json/?type=834 still delivers the 7 top-level items.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 12:54:12 +01:00
orascheandClaude Opus 5.5 e14f94ba63 SSR auto-purge: purge only the pages that show a saved record, megamenu purges everything
SsrPurgeTargetResolver finds the paths of a saved VITEC or news record: its detail URL, the pages that select it (sys_refindex), product lists by category or all products, list plugins of its type and the records embedding it one level deep. URL, visibility and timing changes, the newest news, events, unknown tables and more than 150 paths still purge everything. Megamenu records and the megamenu element now purge everything (before: only its storage page). New read-only command vitec:ssr-purge-targets shows what a save would purge. Reference index rebuilt (117 updates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:16:44 +01:00
orascheandClaude Opus 5.5 06b9bc8293 SSR cockpit: label the response time, leave test reports out of the client error counts (0114-FE)
The big response time number is labelled last check. ClientErrorClassifier tells smoke and curl test reports and bot reports apart; tests are counted separately and left out of the tile, the hourly bars, the list and the dashboard tile, bots keep a label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:42:57 +01:00
orascheandClaude Fable 5.1 4bc38b1760 Product content check as page content (vitec_productcheck)
The product audit of public/product-check/ becomes data for the React frontend: new content element vitec_productcheck (plugin registration, wizard entry, TypoScript USER func) delivers under content.productcheck either the overview (every product with one {ok,count} check per field - hero, photos, og, video, teaser, subtitle, description, description2, highlights, capabilities, applications, features incl. icon count, resources, datasheets, solutions - plus per-field summary) or, with ?check=<uid>, the detail of one product (texts as stored HTML, images with original URL and 480px thumbnail, videos, feature cards with icons, resources, datasheets, solutions). ProductCheckService ports the PDO queries of the standalone page to Doctrine; the renderer is always USER_INT so the audit is never served from the page cache; check is excluded from the cHash. Migration adds the element to page 607 (/products/product-check). The standalone page stays untouched. Verified on DEV: overview 85 products in 0.13 s warm, detail 0.2-0.9 s, thumbnails resolve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 11:34:59 +01:00
orascheandClaude Opus 5.5 b36dceb486 Scheduler: VITEC tasks instead of separate crontab lines
New command vitec:scheduler-setup creates the scheduler group VITEC with the tasks vitec:ssr-health every 5 minutes and vitec:client-error-cleanup daily at 04:23, safe to run again; --disabled creates them switched off, --enable switches them on after the old crontab lines are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:21:53 +01:00
orascheandClaude Opus 5.5 90cb2faaf9 SSR alert mail recipients maintainable in the site settings
New site setting vitec.ssr.alertEmails (Site Management, Settings, VITEC; one address per entry). vitec:ssr-health reads the recipients from there, invalid entries are skipped and logged, SSR_ALERT_EMAIL in the env file stays as fallback. The cockpit shows the recipients in the live band. Pre-filled with the address that was in the env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:13:37 +01:00
orascheandClaude Opus 5.5 994f9f2a1b SSR cockpit and dashboard tiles: info button with tooltip
Every tile of the module and the three SSR dashboard widgets has a round question mark button top right. Hovering it or focusing it with the keyboard shows a tooltip that explains what the tile shows. Pure CSS, anchored to the card so it never leaves the tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:05:33 +01:00
orascheandClaude Opus 5.5 62f3f5394e SSR dashboard tiles in the cockpit look
The three SSR widgets render dark VITEC tiles like the cockpit module: status with pulse, cache ring and last purge, statistics with the 24 h availability strip and number tiles, errors with hit rate ring and split bar. Shared markup in CockpitHtml, styles in dashboard-ssr.css loaded per widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:59:49 +01:00
orascheandClaude Fable 5.1 cda26947f2 Ordner mit genau einer Uebersichtsseite leiten auf diese (RF Gateway, AV over IP & KVM)
Nachtrag zu 1b72e65 (Vorschlag Content-Import-Session): statt /products
zeigen die Redirects der Ordner rf-gateway und av-over-ip-and-kvm samt
ihrer Alt-URL-Redirects auf /products/avedia-modular-system bzw.
/products/aligo. Migration folder_redirect_targets_20261111 (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:59:02 +01:00
orascheandClaude Fable 5.1 1b72e65a1d 301 fuer die /products-Gruppenordner auf /products (Entscheidung Harry 11.11.)
Die 11 Gruppenseiten des Produktbaums wurden am 10.11. im Backend zu
Ordnern (doktype 254) und antworteten seitdem 404 (Seobility-Crawl 3:
8 'technische Probleme'). Harry: Absicht -> Migration
folder_pages_redirects_20261111 (angewendet) legt 301 auf /products an
(mit und ohne Slash) und biegt aeltere Redirects, die auf einen dieser
Ordner zeigten, ebenfalls auf /products um. Inhalte auf den Ordnern
bleiben unangetastet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:56:43 +01:00
orascheandClaude Opus 5.5 67ded6e502 SSR module: dark VITEC cockpit with live charts
Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:52:46 +01:00
orascheandClaude Fable 5.1 a9923e19e0 Paginierte/gefilterte Listen: eigener Titel und Description (Page N, Kategorie)
Mit dem selbstreferenzierenden Canonical (07f74e3) zaehlten
/insights/news?page=2 und ?category=... als eigene Seiten mit dem
Titel von Seite 1 (Seobility: 3 doppelte Titel, 2x Duplicate Content).
VitecMetaHandler haengt jetzt ' - Page N' bzw. die Kategorie an Titel
und Description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:49:48 +01:00
orascheandClaude Fable 5.1 8cdcd99eb3 Why-VITEC-Band auf den 3 alten Kategorieseiten (0106-FE, Entscheidung Harry)
migrations/why_band_unwrap_20261111.php: loest die vitec_cols-Container
hinter dem Why-VITEC-Intro auf graphics-and-multi-display,
infrastructure-and-accessories und video-capture auf, stellt die 12 Karten
als icon_card top-level hinter das Intro und sortiert die Hauptspalte neu
(Inspect default, --apply). Angewandt, Seiten gepurgt und gewaermt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:25:29 +01:00
orascheandClaude Fable 5.1 086a178341 SSR module: rebuilt on TYPO3 module layout, cards and core components
Ticket 0096-FE (aehm): the module now uses the core Module layout (DocHeader with reload and shortcut, module-body spacing), a Status card as description list with one badge style, an Actions card with core buttons and icons, help texts below the buttons and TYPO3 modal confirmations (ssr-module.js) instead of window.confirm, and three protocol cards. Purge protocol rows show a one-line time, the trigger as badge plus user, paths as count + first two + disclosure, result and duration right-aligned, with 30/100/500 row switches. Status shows the last health sample with relative age and the cache limit (new optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env, SsrPurgeService::getCacheLimitMb). ssr-warm-progress.js now also disables the Warm button with a spinner while a run is active. backend-ssr.css holds only layout glue with core CSS variables (dark mode safe). Verified in the backend: layout, modal, running progress bar, protocol tables.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:15:42 +01:00
orascheandClaude Fable 5.1 07f74e372b Antworten auf 0100/0102/0104/0108-FE: Canonical paginierter Listen, JSON-LD-Breadcrumb auf den Datensatz, Story-Liste ohne crops, Startseiten-Daten
VitecMetaHandler: /insights/news?page=2 (und ?category=) bekommt einen
selbstreferenzierenden Canonical statt Seite 1 (0100-FE 5.1).
PageJsonLdRenderer: auf Produkt-, News- und Story-Detailseiten ersetzt
der Datensatz (Titel + URL) die Shell-Seite als letztes Breadcrumb-
Element (0108-FE F11). UsecaseListJsonRenderer: cardImage.crops aus den
Listen-Karten entfernt, ~20 kB pro Seite mit Testimonial-Band
(0102-FE 5). Migration fe_answers_fixes_20261111 (angewendet):
Platzhalter 'H1 Header' (/solutions) und 'Featured Product' (Startseite,
Lorem ipsum, CTA auf /product) versteckt; Karte 'Government' auf
/markets/government; Button in #699 'Explore our solutions'; Story CDC
h4 'Product Highlight' -> h3 (0104-FE 4.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:14:49 +01:00
orascheandClaude Fable 5.1 5824981e2b Overview-Import: Why-VITEC-Karten als icon_card (0106-FE)
Der erste Lauf (5bm) legte die neuen Why-VITEC-Baender auf 9 Uebersichts-
seiten als text_only an - das Frontend baut das Icon-Band nur aus icon_card.
Importer legt neue Band-Karten jetzt als icon_card (ohne Icon) an und stellt
vorhandene Band-Karten beim Re-Run um; 36 Karten repariert, Seiten gepurgt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:11:32 +01:00
orascheandClaude Fable 5.1 da2ca2690d SSR module: live progress bar for cache warming
The Cache warming row of the VITEC SSR module now shows a Bootstrap progress bar with a running counter instead of asking for a reload. New ajax route vitec_ssr_health (SsrController::healthJson) returns the node /__health JSON; ssr-warm-progress.js polls it every 2 s while a warm run is active and every 15 s otherwise (pauses in background tabs) and also refreshes the Cache: N pages / M MB line. The health fetch moved into SsrController::fetchHealth, shared by indexAction and the ajax route; its error text is English now. Verified live in the backend: bar, counter and cache line advance during a run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:40:46 +01:00
orascheandClaude Fable 5.1 f4f18e913a Produkt-Uebersichtsseiten: Agentur-Texte aus den "Enriched"-Workbooks importiert
- neues CLI vitec:import-product-overview-workbooks (Dropbox-Lieferung 09.11.,
  15 Workbooks): Hero-Subline/CTA, Intro + Use Case, Why-VITEC-Band (Text-
  karten bzw. Text auf passende Icon-Karten), Range-Intro, Solution-Karten
  mit Link auf die Gruppenseite, CTA-Banner, SEO-Meta; Produktkarten bleiben
  (Record-Teaser), Vergleichstabelle nur mit --comparison
- vitec:seed-product-features --attach-icons: gestagte Icons an vorhandene
  Karten ohne Icon haengen (PRISM 5/5, SFF PRISM 4/5 angehaengt)
- Diagnose-Skripte check_overview_pages_20261110 / check_banners_20261110
- .gitignore: migrations/product_overviews_xlsx_in, product_images_pending

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:37:15 +01:00
orascheandClaude Fable 5.1 2aa8865b23 Tote Partner-Links in alten News entlinkt (22 Links, 10 Artikel)
Entscheidung Harry: Link entfernen, Text behalten. Hosts: Queen's
Awards Magazine, Sam Minton, EET Group, Tecnomultimedia, Sentry Video
Walls, Exertis, InfoComm SEA, SurveyMonkey, Exterity, Eureka Network.
Migration unlink_dead_external_20261110 (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:17:11 +01:00
orascheandClaude Fable 5.1 e357f42b81 Seobility-Crawl 2: Karten-Links, Platzhalter-News, Titel-Kontext ueber Ordner, Descriptions 155 Zeichen
Migration crawl2_fixes_20261110 (angewendet): sechs Karten-Buttons
zeigten auf versteckte/geloeschte Produkt-URLs (ArtioSign/ArtioView ->
/products/aetria, Redirects folgen; X-View-Selbstlink entfernt;
Chassis-Karte auf das verbliebene Produkt). Drei weitere News trugen
noch den InfoComm-Importplatzhalter und doppelten echte Artikel
(JODDB, Bluefin, NAB 2026) -> geloescht + 301; EKOM-Artikel bekommt
seinen Text von der alten /en/news-Seite; byte-identische Datapath-
Kopie (#551) -> 301. Drei Tippfehler. Tote vitec.com-Links in 14
Datensaetzen auf interne Ziele (/markets, /insights/sustainability,
Story Northwestern, /product/fx4, 'vitec.com./' und '&nbsp;'-Links).
SEO-Titel fuer sieben Hauptseiten, Descriptions fuer neun
Kategorieseiten mit < 60 Zeichen.

VitecMetaHandler: Titel-Kontext ueberspringt Ordner-/Shortcut-Ebenen
('End-Points | Products | VITEC' statt 'End-Points | VITEC'); kurze
Story-Titel bekommen '| Success Story'; Meta-Description auf 155
Zeichen gekappt, auch redaktionelle (Seobility: 527 'zu lang').

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:08:34 +01:00
orascheandClaude Fable 5.1 4b6731786d News: Canonical auf Success Story (Feld vitec_canonical_usecase), 13 Paare gesetzt
13 Pressemeldungen tragen denselben Text wie eine Success Story
(Seobility: doppelter Inhalt). Neues Feld am News-Datensatz (Tab
'VITEC SEO', Auswahl der Story); VitecMetaHandler ersetzt damit den
Canonical-Link durch /success-stories/<slug>. Die Meldung bleibt in der
Liste, Suchmaschinen werten die Story als Original. Migration
news_story_canonical_20261110 setzt die 13 Zuordnungen (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:20:03 +01:00
orascheandClaude Fable 5.1 f7b1eacecb Dubletten-Bereinigung nach Seobility: Produkt-Kopie, 10 News, Slugs, /about, Story-Texte
Migration dedupe_20261110 (angewendet): leere Chassis-Kopie geloescht;
bei InfoComm/LG/PRISM/ISE trugen die -1-Kopien den Text, die Basis war
Platzhalter oder leer - Kopien uebernehmen den sauberen Slug; identische
Kopien (Datapath-Uebernahme, Remote-Work-Blog) weg; Records ohne Inhalt
(Global Events, DSEI-2023-Zeiger) weg; WallControl-Award und die beiden
Jahresbotschaften mit sprechenden Slugs; /about (nur Standortliste)
versteckt und auf /insights/who-we-are umgeleitet, Links umgehaengt;
drei Story-Teaser/-Descriptions, die von fremden Stories kopiert waren,
neu gesetzt. Alle alten Adressen leiten per 301, bestehende Redirects
zeigen direkt auf die neuen Ziele (keine Ketten).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:12:34 +01:00
orascheandClaude Fable 5.1 37c4c116d0 SSR-Modul: Client errors mit Details-Aufklapper in jeder Zeile
Bisher gab es den Aufklapper nur bei Meldungen mit Stack. Jetzt hat jede
Zeile "details" mit Zeit, Release, voller URL, kompletter Meldung,
Stack (oder Hinweis "no stack transmitted"), vollem User-Agent und
anonymisierter IP, damit sich jeder Fehler bewerten laesst (Wunsch Harry).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:00:58 +01:00
orascheandClaude Fable 5.1 08c02276e5 Titel-Kontext: Import-Muster 'Titel | VITEC' als ungepflegt werten, direkte Elternseite
Die Kategorieseiten tragen seo_title = '<Titel> | VITEC' (Import), was
die Kontext-Vorlage als gepflegten SEO-Titel las. Der Brand-Suffix wird
vor dem Vergleich abgestreift; die Vorlage baut den Titel aus Seitentitel
+ Elternseite neu auf, der Brand-Suffix folgt wie bisher.

Die Rootline ist root-first; der Helfer las bisher Index 1 (oberste
Ebene, 'Products') statt der direkten Elternseite ('AVoIP & KVM').

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:56:10 +01:00
orascheandClaude Fable 5.1 33bdb8d856 Titel-Kontext auch bei kopiertem SEO-Titel; Description /solutions
Der Import hatte bei fast allen Seiten den Seitentitel 1:1 als seo_title
gespeichert, dadurch griff die Kontext-Vorlage fuer kurze Titel nie. Ein
seo_title, der dem Seitentitel entspricht, gilt jetzt als nicht gepflegt
('Aligo | AV over IP & KVM | VITEC'). Dazu die Meta-Description der
Solutions-Uebersicht (war bis heute der TCA-Platzhaltertext).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:49:52 +01:00
orascheandClaude Fable 5.1 8a652063c5 Seobility-Report 2026-11-10: Backend-Befunde umgesetzt
Crawl nach dem Footer-Fix: 1.094 Seiten (vorher 9). Backend-Anteil:

- Migration seo_report_fixes_20261110: Platzhalter-SEO-Titel auf /solutions
  geleert; News mit verdoppeltem Titel/Slug und Slugs mit Slash bereinigt
  (+Redirects); 8 Tippfehler; zweite Welle toter Alt-Links (datapath.co.uk-
  Absolutlinks, utm-Varianten, /resource-center, /services, /contact-sales,
  /aetria-use-cases, /video-wall-solutions ...) in 48 News ersetzt + 101
  Redirects; Record-Shells /product, /news/article, /success-stories/story
  leiten auf ihre Uebersichten; Meta-Description /datasheets; WordPress-
  Reste in den 17 SEO-Artikeln entfernt; 53 News-Zeiger-Records (type=1
  auf alte /en/news-Seiten, ohne eigenen Text) bekommen den Seiteninhalt
  (Text + Bilder) in den Record und werden normale Artikel - die alten
  Seiten leiten per 301 auf die Artikel.
- VitecMetaHandler: kurze Seitentitel ohne SEO-Titel erhalten ihren
  Bereich als Kontext ('Aligo | AV over IP & KVM | VITEC').
- ProcessedFileReferenceToArray: Alt-Text-Fallback (Referenz-Titel, dann
  Dateititel) fuer Content-Block-Bilder.
- UsecaseListJsonRenderer: Testimonial-Baender liefern nur noch Stories
  mit Zitat, aktueller Markt zuerst, max. 12 (vorher alle 70 = 226 kB auf
  jeder Markt-/Solution-Unterseite, identische Bloecke auf ~90 Seiten).
- NewsJsonRenderer: paginierte Listen-Payloads 15 Min. im hash-Cache
  (USER_INT rendert sonst jede Variante neu; 50 langsame URLs im Report).
- robots.txt host-abhaengig: dev bekommt robots-dev.txt mit der dev-Sitemap
  (Seobility fand '0 Sitemaps', weil die Sitemap-Zeile auf www zeigte).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:47:55 +01:00
orascheandClaude Fable 5.1 5121eaa000 Client-Error-Endpoint POST /api/vitec/client-error (0072-FE / 0077-BE)
Neue PSR-15-Middleware ClientErrorMiddleware: nimmt Browser-Fehler des
React-Frontends als JSON {url, message, stack, release, userAgent}
entgegen und antwortet 204 ohne Body. Schutz: 30 Meldungen pro Minute
und IP (Sliding Window ueber TYPO3-RateLimiter, 429 + Retry-After),
8-KB-Body-Limit (413, Content-Length und tatsaechliche Bytes), kein
JSON-Objekt oder leere message = 400, GET = 405. Keine Session, keine
Cookies; der /_json-Praefix funktioniert ebenfalls.

Speicherung in der neuen Tabelle tx_vitec_clienterrorlog mit
anonymisierter IP (IPv4 letztes Oktett 0, IPv6 /48). Sichtbar als
Tabelle "Client errors (last 24 h)" im Modul VITEC SSR (mit Stack als
Aufklapper) und als Zeile "Client errors reported (24 h / 7 d)" in der
Dashboard-Kachel VITEC: SSR statistics.

CLI vitec:client-error-cleanup [--days=30] loescht Eintraege aelter als
30 Tage (taeglich per Cron).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:25:23 +01:00
orascheandClaude Fable 5.1 ff6f86b829 Footer-Spalten: Linktexte an die Relaunch-Struktur angeglichen
Die Footer-HTML-Bloecke (pid 19, #132-#135) trugen noch die Benennungen
der alten Website (Venues, Military, Accommodation, VITEC OG, PCIe
Encoder, Interactive TV Portal ...), obwohl die Ziele seit
seo_fixes_20261109 stimmen. Migration fix_footer_link_texts_20261110:
Linktext + title-Attribut = Navigationstitel der Zielseite (Produkt-
Records per Titel), Blatt-Eintraege, die auf dieselbe Seite wie ein
frueherer Eintrag oder ein Eltern-Eintrag zeigen, entfernt, die
Uebersichts-Platzhalter Healthcare (/markets) und Telemetry (/products)
gestrichen, leere Listen bereinigt. Redaktionelle Sonderfaelle: Digital
Signage in der Solutions-Spalte -> Loesungsseite, Fx4 Series -> /product/
fx4, IP/RF Gateways & Transcoders -> /products/gateways-and-transcoding,
HelpDesk-Seite als 'HelpDesk Access' (externer Login-Link steht
darueber). Externe Links, t3://-Links und Ueberschriften unveraendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 16:05:05 +01:00
orascheandClaude Fable 5.1 be4bb44c40 Content-Block-Bilder als verarbeitete WebP mit srcset ausliefern
Lighthouse (Mobile) auf der Startseite: Score 40, LCP 25 s, 42 MB. Ursache:
nb-headless-content-blocks serialisiert Bildfelder (hero_bgimage,
intro_image, Karten, Banner) als publicUrl des ORIGINALS - 3,7 und 13,4 MB
JPEGs gingen unveraendert an den Browser, waehrend News-/Produktbilder
laengst durch den ImageService laufen.

- XCLASS Headless\ProcessedFileReferenceToArray (das Paket instanziiert
  seinen Serializer per makeInstance): publicUrl = WebP max. 1920 px mit
  Redaktions-Crop, dazu originalUrl/originalSize, srcset 480-1920
  (Stufen, die aufs Original zurueckfielen, entfallen), width/height,
  mimeType, size. SVG/GIF unveraendert (+Metadaten), Videos/PDFs bekommen
  mimeType/size. Exception-safe: Fallback auf Stock-Payload.
- .htaccess: fileadmin/_processed_/* mit Cache-Control max-age=1 Jahr,
  immutable (Hash im Dateinamen).

Effekt Startseite (gleiche Messbedingungen): LCP 25,1 -> 3,8 s, Bilder
18,7 -> 1,1 MB, gesamt 42 -> 22,6 MB; der Rest ist das 20-MB-Testvideo im
Hero (redaktionell) und Frontend-Themen (Ticket 0085-BE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 15:53:20 +01:00
orascheandClaude Fable 5 dc9b5fce47 News-Detail: FAQ-Band + Produktkarten, tote Datapath-Links migriert
Die SEO-Artikel trugen (a) eine FAQ-Sektion als nackte <p>-Paare im
Bodytext und (b) ~150 Links auf die alte datapath.com-Struktur
(/datapath-products/..., alte /markets/...-Namen, /video-wall-solutions)
- alles 404 auf der neuen Seite.

- NewsJsonRenderer (Detail-Modus): extractFaqBand() schneidet die
  'Frequently asked questions'-Sektion aus dem Bodytext und liefert sie
  strukturiert als faq {heading, items[{question, answer(HTML)}]} fuers
  Frontend-Accordion (fail-safe: unter 2 sauberen Paaren bleibt alles
  unveraendert); collectMentionedProducts() liefert bis zu 6 Karten
  (title, text, image, link) fuer im Text verlinkte Produkt- und
  Kategorieseiten. Beide Felder additiv; Listen unveraendert.
- Migration fix_datapath_links_20261110: 148 News-Records auf die neuen
  Ziele umgeschrieben (Mapping alte Datapath-IA -> /product/<slug> bzw.
  /products/<slug>/Markets/Solutions, Ziele gegen DB verifiziert) und
  113 sys_redirects 301 angelegt (alte Pfade mit und ohne Slash - greift
  fuer externe Backlinks, sobald datapath.com hierher zeigt).

Live verifiziert: Beispielartikel liefert faq (8 Fragen, Antworten mit
funktionierenden Links), 4 Karten, Bodytext ohne FAQ-Absaetze und ohne
Datapath-Links; normale News unveraendert (faq null); Redirects 301 ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 12:52:14 +01:00
orascheandClaude Fable 5 5c640d02f7 News: redaktionell eingefuegtes FAQPage-JSON als echtes Schema ausgeben
17 SEO-Artikel tragen ein rohes FAQPage-JSON-Objekt in einem
abschliessenden Absatz ihres Bodytexts - das Frontend renderte es als
sichtbaren Text, Google sah es nie als strukturierte Daten.

Neue Klasse Seo\EmbeddedJsonLd trennt solche schema.org-Bloecke aus dem
RTE-HTML (fail-safe: was nicht sauber dekodiert, bleibt stehen):
- NewsJsonRenderer: Detail-bodytext und Listen-Exzerpt ohne die Bloecke
- PageJsonLdRenderer: die dekodierten Nodes wandern in den @graph der
  Seite (FAQPage landet damit als <script type=application/ld+json> im
  Head, den das Frontend bereits injiziert)
- VitecMetaHandler: Description-Fallback aus dem Bodytext ohne JSON-Muell

Verifiziert an 3 Artikeln: @graph mit NewsArticle + FAQPage (8-9 Fragen),
kein FAQPage-Rest im Textkoerper, kein sichtbarer JSON-Absatz im HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:36:48 +01:00
orascheandClaude Fable 5 c9b00e6f10 News-Liste: serverseitige Pagination (Kontrakt 0070-FE)
Die Uebersichtsliste (news_newsliststicky/news_pi1 ohne limit und ohne
Auswahl, d.h. genau /insights/news) liefert jetzt pro Request nur die
Karten der angefragten Seite: ?page=<n>&category=<slug>, Antwort additiv
mit pagination {page,pages,itemsPerPage,total}, categories[] mit
Gesamt-Zaehlern und activeCategory. page klemmt auf die letzte Seite,
unbekannte category faellt auf alle zurueck, Sortierung serverseitig
datetime DESC, itemsPerPage aus settings.list.paginate (Default 12).

Caching: page/category stehen in cacheHash.excludedParameters, der
TYPO3-Seitencache unterscheidet die Varianten also nicht - das Element
konvertiert sich deshalb im paginierten Fall per
convertToUserIntObject() zu USER_INT (EXT:headless wrappt den
INT-Marker selbst); ETag/304 je Query-Variante verifiziert, der
SSR-Cache haelt eine Variante pro Pfad+Query.

Karten im paginierten Modus in schlanker Form (Felderliste aus
0070-FE); media[] traegt jetzt ueberall alternative (Alt-Text-Fallback
B5). Teaser-Listen mit limit und Selected-/Search-Listen unveraendert;
ohne pagination-Feld bleibt das Frontend beim bisherigen Verhalten.

JSON der Liste: 700 kB -> ~100 kB pro Seite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:11:55 +01:00
orascheandClaude Fable 5 be163a88c2 SSR health alerts: mail texts in English
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:00:56 +01:00
orascheandClaude Fable 5.2 8f8ae039d4 Real JSON null for empty product feature; SSR warm-cache button and CLI
Ticket 0068-FE: ProductFeatureJsonRenderer returned json_encode(null), which headless passes on as the STRING null (it only decodes object/array-looking strings) and crashed the front end. It now returns an empty string and the TypoScript field sets ifEmptyReturnNull = 1, giving a real JSON null. No other renderer used the pattern. Ticket 0066-FE: SsrPurgeService::warm() posts to the node /__warm endpoint (derived from SSR_PURGE_ENDPOINT, same secret), exposed as a Warm cache button with confirmation in the VITEC SSR module, a Cache warming progress row fed by /__health, and the CLI vitec:ssr-warm [paths]. Warm runs are deliberately not written to the purge log. Verified: two-path run 2/2 done, affected page delivers productfeature null.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:59:24 +01:00
orascheandClaude Fable 5 cb53ff67ee SSR dashboard: new errors-and-hit-rate tile fed by /__stats samples in vitec:ssr-health (stat_* columns in healthlog)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:51:13 +01:00
orascheandClaude Fable 5 bc2e031b0c SSR health: alert mails on ok/down transitions + auto-failover to SPA after N consecutive misses (SSR_ALERT_EMAIL / SSR_FAILOVER_AFTER in var/vitec-ssr-purge.env)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:18:06 +01:00
orascheandClaude Fable 5 b9736b9ac7 SEO-Audit 2026-11-09: Meta-Komposition, Sitemap, Redirects, robots.txt
- VitecMetaHandler: Record-Metadaten fuer News-/Produkt-/Story-Details
  (Titel, Description, og:/twitter:-Tags, og:image), '| VITEC'-Suffix,
  Fallback-Description aus Seitenfeldern, twitter:card large_image,
  Story-Canonical auf die oeffentliche Kurz-URL
- PageJsonLdRenderer: Product- und NewsArticle-Nodes auf Detailseiten
  (News-Details hatten <title>article</title> und kein Schema)
- NewsJsonRenderer: Listen ohne Volltext-Body (JSON 2.6 MB -> ~0.6 MB)
- XML-Sitemap: News-Teil konfiguriert (Ordner 11+442, Detailseite 21),
  Record-Sitemaps fuer Produkte und Success Stories (kurze URLs),
  Route-Enhancer fuer alle Teile, Shell-/Altseiten ausgeschlossen
- Migration seo_fixes_20261109: 161 Self-Canonicals geleert (warfen u.a.
  Home, Markets, Solutions, Support aus der Sitemap), noindex fuer
  technische und alte News-Seiten, 71 Redirects Alt-URLs -> neue Ziele,
  51 tote Footer-Links ersetzt
- public/robots.txt mit Sitemap-Verweis

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:11:01 +01:00
orascheandClaude Fable 5 be519f9bc1 Context menu purge: drop .js from the callback module specifier
The backend context menu appends .js itself when importing the callback module; with the extension already in the attribute the browser requested ssr-context-menu.js.js and the click died silently. Confirmed working in the backend after the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:14:50 +01:00
orascheandClaude Fable 5 5b524bfce9 Page context menu: Purge from SSR cache
SsrPurgeItemProvider adds the item behind Clear cache for real pages (doktype < 199) whenever the purge endpoint is configured. The callback module posts the page uid to the new vitec_ssr_purge_page ajax route, which resolves the page path through the site router, purges it via SsrPurgeService (trigger contextmenu:username, logged as usual) and reports back as a toast notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:10:30 +01:00
orascheandClaude Fable 5 fa63871da7 SSR control center: English UI strings throughout
Widgets, backend module template and flash messages now match the rest of the backend, which is English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:03:40 +01:00
orascheandClaude Fable 5 91c2ceb5b1 Dashboard widgets: fix group name (systemInfo, not systemInformation)
The group identifier comes from EXT:dashboard DashboardWidgetGroups.php; with an unknown group the widgets never appeared in the add-widget dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:59:39 +01:00
orascheandClaude Fable 5 63f950f9db SSR control center: auto-purge webhook, dashboard tiles, backend module
Package 1: SsrPurgeService posts to the node /__purge (endpoint+secret in var/vitec-ssr-purge.env, outside git) and logs every attempt to tx_vitec_ssrpurgelog. DataHandler hooks queue per request and flush once: pages with nav-relevant field changes and all tx_news_/tx_vitec_ records purge everything, other page/content edits purge the hosting page path. Gated by the routing flag and the new Site Set switch vitec.ssr.autoPurge. Flipping the SSR switch ON purges everything once. CLI: vitec:ssr-purge, vitec:ssr-health (samples /__health into tx_vitec_ssrhealthlog, 14 days kept, scheduler-ready). Package 2: dashboard widgets vitecSsrStatus (live health, release, switch, last purge) and vitecSsrStats (purge counts, failures, uptime, response time). Package 3: backend module web_vitecssr with manual purge (paths or everything) and both protocols. Tested end to end against the running node (purge single/all 200, health sample logged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:46:16 +01:00
orascheandClaude Fable 5 d52d49974f SSR switch-on fix: proxy the DirectoryIndex homepage detour
First switch-on with a running node: Apache mod_dir turns / into /index.html via the DirectoryIndex subrequest before the per-dir root rule can proxy it; the node then asked for /_json/index.html and rendered a cacheable 404 homepage. New flag-gated rule proxies index.html as / to the node. Companion fix outside git: public/_frontend/.htaccess (build output, gitignored) overrides the root rewrites for /_frontend/*, so release assets only existing on the VPS were answered by its SPA fallback with index.html - HTML served as JS; the flag-gated asset proxy now also sits in that file before the fallback, and the frontend build must adopt it in its .htaccess template (ticket 0059-BE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:20:07 +01:00
orascheandClaude Fable 5 d153cc2eba README: drop the dev basic auth credentials
The vitecdev password has been rotated today (htpasswd on the server, new value distributed via the password manager and set as VITEC_API_AUTH on the SSR VPS). Credentials do not belong in the repository; the leaked value is invalid as of now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 18:32:29 +01:00
orascheandClaude Fable 5 232aaf2ca8 SSR prep: asset proxy behind flag, forwarded proto, header hygiene
Ticket 0054-FE section 5: (1) with the SSR flag on, /_frontend/assets/* is now proxied to the VPS release as well, deliberately without a file-exists guard so stale local SPA hashes cannot shadow the release; the proxy rules tag requests with env SSRPROXY and mod_headers adds X-Forwarded-Proto: https. (3) If-None-Match comparison in JsonCacheHeaders accepts the -gzip suffix that mod_deflate appends, so browser 304s work through compression. (4) X-TYPO3-Cache-Tags*/-Debug-Cache/-Cache-Lifetime/-Parsetime are stripped from responses (onsuccess and always tables - they leak record ids and overflowed the SSR node 16 KB header limit on news pages).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 18:20:21 +01:00
orascheandClaude Fable 5 351349ec1f JSON API: Cache-Control no-cache + ETag/304 revalidation
New JsonCacheHeaders middleware (outer, frontend stack): GET/HEAD 200 responses under /_json get a single Cache-Control: no-cache, Expires: 0 and a content md5 ETag; If-None-Match answers as empty 304. Logged-in visitors (fe_typo_user) keep TYPO3 headers. Expires: 0 is deliberate so mod_expires leaves the response alone. .htaccess: the global Header unset ETag now skips /_json (KEEP_ETAG env, carried across the mod_rewrite internal redirect as REDIRECT_KEEP_ETAG). Fulfils tickets 0011-FE point 3 and 0055-BE question 6; asset immutable headers were already live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 17:03:05 +01:00
orascheandClaude Fable 5 dcd713f222 Site settings: fill schema.org organization data, drop scaffold examples
All seo.* settings now carry real values (sources: vitec.com footer + privacy policy, KBIS for the legal name VITEC SA): canonical base, org name/legalName, address Chatillon, phone, sameAs LinkedIn/YouTube, logo at fileadmin/vitec-brand/vitec-logo.png (served 200, verified in the home jsonLd as absolute www.vitec.com URL). Email left empty - no public address exists. Removed the my.vitec.setting / my.seoRelevantSetting scaffold examples from the set definition and the my.example.setting value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 14:03:54 +01:00
orascheandClaude Fable 5 14623ed4fa gitignore: ignore all of config/system/, not just two files
additional.php.save (editor backup) was untracked and unignored - one git add -A away from pushing config internals. settings.php holds credentials, so the whole directory is off limits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 13:43:19 +01:00
orascheandClaude Fable 5 2c0da281bd SSR switch: site setting vitec.ssr.enabled gates .htaccess proxy to SSR node
- Site set Vitecset: new bool setting vitec.ssr.enabled (default off,
  backend switch under Site Management > Settings > VITEC Settings)
- SsrFlagService derives var/vitec-ssr-on.flag from the setting;
  SyncSsrFlag middleware (frontend + backend) re-syncs per request
  because TYPO3 v14 fires no event on site settings save;
  vitec:ssr-flag CLI command for status/--sync
- public/.htaccess: flag-gated block proxies page requests [P] to
  https://vitec-ssr.evomedien.de (mod_proxy incl. https target verified
  on this host 2026-11-09), same exclusions as the SPA fallback block;
  completely inert while the flag file is absent
- Emergency off without TYPO3: delete var/vitec-ssr-on.flag
- Tested end to end: flag on -> pages proxied to VPS Caddy (502 while
  the SSR node is not deployed yet), /_json untouched; flag off ->
  SPA shell exactly as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 13:35:27 +01:00
orascheandClaude Opus 5.5 bd7a145afa Products: categories and sub-categories become clickable pages
The 2026-11-02 menu build created the 3 categories and 9 sub-categories
as sysfolders (labels); every level must be a clickable page.

- build-product-overviews: six sub-categories adopt the 2026-10 category
  pages (Joe's workbooks; content, slug and SEO kept, sitemap name as
  nav_title), the remaining folders become pages with generated content,
  Capture & Graphics gets the main column of Graphics & Multi-Display and
  Video Capture copied in. Pages move/convert by direct UPDATE (the
  redirects slug hook would rewrite the flat child slugs); three 2026-10
  pages leave the menus but stay reachable. Warns about a sys_redirect
  on a target slug.
- ProvideProductCatalog: categories[].link and groups[].link (additive),
  menu title = nav_title when set, only filled groups/categories count.
  The menu search reads both levels as pages too.
- build-why-vitec-bands: band at the top of the main column; on pages
  with an existing "Why VITEC?" section the context's icons go onto the
  cards whose title matches the icon theme (16 icons on 4 pages).
- No browser cache headers on the page JSON: headless enables
  sendCacheHeaders and TYPO3 14 caches pages for a year by default, so
  every /_json/ response carried max-age=~31536000.
- Spec v1.32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:05:04 +01:00
orascheandClaude Opus 5.5 73e4298b75 Insights spotlight: news from folders 11 + 442, source editable in the menu
- Megamenu entries with the catalog or spotlight layout get two editor
  fields, "Data source folders" (source_pages) and "Data source
  categories" (source_categories); the extension stores them, the site
  package's listener reads them from the entry row.
- ProvideInsightsSpotlight reads its news from those folders (empty =
  11 + 442; the newer posts live in 442, the query only read 11) and,
  when set, only from those categories (Insights: Blog Post + Press
  Release). Newest datetime first, three cards.
- Pages carrying the menu are tagged with the news and event tables, so
  saving such a record refreshes the menu without a cache flush.
- VITEC field descriptions via a TCA override; spec v1.31, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:59:56 +01:00
orascheandClaude Fable 5 ad1f3c179c why-vitec band: move it before the "Product range" intro
- new migration fix_why_band_position.php: on the 7 overview pages the
  band (Why VITEC? intro + icon cards) had landed between the "Product
  range" heading and its product cards; reorders the top-level column
  to hero -> band -> Product range -> cards (ticket 0042-FE section 3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:51:42 +01:00
orascheandClaude Fable 5 f525ce3c27 product-check: Features column with icon coverage
- new "Features" column: check with cards-and-icons count per product
  (e.g. "5·4i"), header carries the filled total like the other columns
- detail pane lists every feature card with icon thumbnail (click to
  enlarge), title and text; cards without an icon show a red cross on
  the icon slot - the missing agency icon sets are visible at a glance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:17:50 +01:00
orascheandClaude Fable 5 6ff9c3e7a8 seed-product-features: two more icon-matching tiers
- token-containment pass: theme words inside the card title
  (ManagementFoundation -> "Aetria management foundation")
- last-pair rule: a complete set (#icons == #cards) with exactly one
  icon and one card left over pairs them (FlexibleConnection,
  EndToEndEncryption, HighPerformance, 4KSourcetoDisplays, AVoverIP)
- genuine mismatches (aligo-workstation, EZ-TV Platform, AvediaPlatform
  Artio icons) still stay unmatched for the editors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:03:50 +01:00
orascheandClaude Fable 5 46b12d4a58 product.features: Why-choose cards with icons (contract 0036-FE)
- new inline table tx_vitec_product_feature (title, cardtext, FAL icon)
  + `features` field on the product record; ProductShowJsonRenderer
  emits product.features {heading, items[{title, text, icon}]} and null
  while a product has no cards, so the front end keeps its
  applications-HTML fallback
- new vitec:seed-product-features: seeds the cards from the
  applications <p><strong> pairs and matches the staged agency icons by
  normalised title (camelCase split, stop words, Levenshtein <= 3 typo
  tolerance); products with existing cards are never touched
- new vitec:build-why-vitec-bands: "Why VITEC?" intro + icon_card band
  on the 7 overview pages with a staged icon set (the contract's
  "centered" variant does not exist - documented in ticket 0039-BE);
  vision/visionav sub-group sets stay unassigned
- product-check page: sortable title column and an Open Product button
- new migration fix_colombia_quote.php: stray quote element #12037
  removed, quote_role set (ticket 0038-FE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 19:55:01 +01:00
orascheandClaude Fable 5 3ccb9b149e Stage the 2026-11 product asset dump: ignore migrations/product_images_in2/
The agency "Products Hero page" delivery (banners, product photos) is
staged per product slug under migrations/product_images_in2/ and
imported with the existing vitec:import-product-images command;
the staging directory stays out of the repo like product_images_in/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:32:42 +01:00
orascheandClaude Fable 5 d26c31e6d1 Product content check: live audit page under /product-check/
Standalone PHP page (public/product-check/index.php), deliberately NOT a
plugin so the React front end needs no counterpart. Reads fresh from the
DB on every request (settings.php PDO pattern):

- alphabetical table of all products, header row with 14 field columns
  (hero/photos/og/video, the text fields, resources/datasheets/solutions),
  green check with count / red cross per cell, filled-count per column
- every row expands to a detail pane: all texts rendered, all images as
  thumbnails with a click-to-enlarge lightbox, relations resolved
  (inline resources, datasheet/solution MM), flags, detail page link
- refresh button reloads with a cache buster; noindex + no-store;
  refuses to serve on www.vitec.com

The static badge view (migrations/build_product_audit.php ->
/product-audit/) stays as is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:13:23 +01:00
orascheandClaude Fable 5 ed3ced42b3 Product content check: live audit page under /product-check/
Standalone PHP page (public/product-check/index.php), deliberately NOT a
plugin so the React front end needs no counterpart. Reads fresh from the
DB on every request (settings.php PDO pattern):

- alphabetical table of all products, header row with 14 field columns
  (hero/photos/og/video, the text fields, resources/datasheets/solutions),
  green check with count / red cross per cell, filled-count per column
- every row expands to a detail pane: all texts rendered, all images as
  thumbnails with a click-to-enlarge lightbox, relations resolved
  (inline resources, datasheet/solution MM), flags, detail page link
- refresh button reloads with a cache buster; noindex + no-store;
  refuses to serve on www.vitec.com

The static badge view (migrations/build_product_audit.php ->
/product-audit/) stays as is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:09:23 +01:00
orascheandClaude Fable 5 8e173b1a85 Hero placeholder fieldname: vitec_hero_bgimage (content block prefix)
The vitec/herosection content block has prefixFields - the real column
and sys_file_reference fieldname is vitec_hero_bgimage; the JSON merely
strips the prefix. The first apply wrote the 20 references with the bare
name, so they existed but never rendered (no herosection anywhere had an
image, so there was no working example to catch it against).

- BuildProductOverviews: attach/backfill/skip-check now use
  vitec_hero_bgimage
- new migration fix_overview_hero_refs.php: renames the existing 20
  references in place (no re-attach) and sets the counter column

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 18:42:06 +01:00
103 changed files with 13637 additions and 159 deletions

No files matched your search

+8 -2
View File
@@ -9,8 +9,10 @@
.env .env
.env.* .env.*
!.env.example !.env.example
/config/system/settings.php # The whole directory, not just the two known files: editor backups like
/config/system/additional.php # additional.php.save would otherwise slip into a careless `git add -A`
# (settings.php holds credentials).
/config/system/
auth.json auth.json
# -------------------------------------------------- # --------------------------------------------------
@@ -135,9 +137,13 @@ migrations/submarkets_xlsx_in/*
migrations/subsolutions_xlsx_in/* migrations/subsolutions_xlsx_in/*
migrations/solutiongroups_xlsx_in/* migrations/solutiongroups_xlsx_in/*
migrations/product_images_in/ migrations/product_images_in/
migrations/product_images_in2/
migrations/markethero_xlsx_in/ migrations/markethero_xlsx_in/
migrations/product_categories_xlsx_in/ migrations/product_categories_xlsx_in/
# generated internal report (php migrations/build_product_audit.php) # generated internal report (php migrations/build_product_audit.php)
public/product-audit/ public/product-audit/
migrations/datapath_stories_in/ migrations/datapath_stories_in/
migrations/product_overviews_xlsx_in/
migrations/product_images_pending/
-4
View File
@@ -168,7 +168,3 @@ bash fix-asset-perms.sh # fix asset permission
[GPL‑2.0‑or‑later](LICENSE) — © evomedien. [GPL‑2.0‑or‑later](LICENSE) — © evomedien.
<div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div> <div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div>
vitecdev
17ZJfy75sJoU68J6
+17
View File
@@ -41,6 +41,23 @@ languages:
solr_core_read: core_en solr_core_read: core_en
rootPageId: 1 rootPageId: 1
routeEnhancers: routeEnhancers:
# Pretty, resolvable URLs for ALL XML-sitemap parts. The typo3/seo-sitemap
# set maps only "pages"; links to the other parts were generated as
# ?sitemap=<name> query URLs that the renderer cannot resolve (it reads
# tx_seo[sitemap]) - the index then returned itself. SEO audit 2026-11-09.
Sitemap:
type: Simple
routePath: 'sitemap-type/{sitemap}'
aspects:
sitemap:
type: StaticValueMapper
map:
pages: pages
news: news
products: products
stories: stories
_arguments:
sitemap: 'tx_seo/sitemap'
ProductPlugin: ProductPlugin:
type: Extbase type: Extbase
limitToPages: limitToPages:
+19 -5
View File
@@ -1,13 +1,27 @@
vitec.debugMode: true vitec.debugMode: true
my.example.setting: Test
menu.footer.pageUids: '1,2,3' menu.footer.pageUids: '1,2,3'
menu.meta.pageUids: '5' menu.meta.pageUids: '5'
megamenu.contentUid: 10366 megamenu.contentUid: 10366
vitec.storyDetailPid: 26 vitec.storyDetailPid: 26
felogin.pid: 520 felogin.pid: '520'
# felogin v14 und EXT:headless belegen beide templateRootPaths.10 - je nach
# Set-Reihenfolge gewinnt das HTML-Template. Das Setting speist genau diesen
# Index, darum hier fest auf die Headless-JSON-Templates zeigen:
felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/' felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/'
felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/' felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/'
felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/' felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/'
seo.site.canonicalBase: 'https://www.vitec.com'
seo.site.name: VITEC
seo.organization.name: VITEC
seo.organization.legalName: 'VITEC SA'
seo.organization.logoUrl: /fileadmin/vitec-brand/vitec-logo.png
seo.organization.sameAs: 'https://www.linkedin.com/company/vitec-multimedia/, https://www.youtube.com/user/vitecmm'
seo.organization.phone: '+33 1 46 73 06 06'
seo.organization.street: '99 rue Pierre Sémard'
seo.organization.postalCode: '92320'
seo.organization.locality: Châtillon
seo.organization.country: FR
vitec.ssr.enabled: true
# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap
# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09.
seo.sitemap.pages.excludePagesRecursive: '10,21,26,106'
vitec.ssr.alertEmails:
- info@evomedien.de
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// Puts the vitec_productcheck content element on the page /products/product-check
// (uid 607) once. Idempotent: a second run reports the existing element.
// Run on dedi: php migrations/add_product_check_element_20261112.php
error_reporting(E_ALL); ini_set('display_errors', '1');
$pageUid = 607;
$root = null; $dir = __DIR__;
for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); }
if ($root === null) { exit("no root\n"); }
$db = (require $root . '/config/system/settings.php')['DB']['Connections']['Default'];
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch();
if ($page === false) { exit("page $pageUid not found\n"); }
echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n";
$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch();
if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); }
$now = time();
$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted)
VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)');
$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']);
echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n";
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
// READ-ONLY: hero references + sys_file rows of the three products that got a banner on 2026-11-10
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
foreach ([25, 24, 22] as $uid) {
$p = $pdo->query("SELECT uid, slug FROM tx_vitec_domain_model_product WHERE uid = $uid")->fetch();
echo "\n=== #{$p['uid']} {$p['slug']}\n";
foreach ($pdo->query("SELECT r.uid ref, r.uid_local, r.hidden, f.identifier, f.tstamp FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'heroimage' AND r.uid_foreign = $uid ORDER BY r.sorting_foreign") as $r) {
echo " heroimage ref#{$r['ref']} file#{$r['uid_local']} {$r['identifier']} (file tstamp " . date('Y-m-d H:i', (int)$r['tstamp']) . ")" . ($r['hidden'] ? ' HIDDEN' : '') . "\n";
}
foreach ($pdo->query("SELECT uid, identifier, tstamp FROM sys_file WHERE identifier LIKE '/product_import/{$p['slug']}/%'") as $f) {
echo " sys_file#{$f['uid']} {$f['identifier']} (" . date('Y-m-d H:i', (int)$f['tstamp']) . ")\n";
}
}
echo "\n=== feature icons prism/sff ===\n";
foreach ($pdo->query("SELECT f.product, f.title, (SELECT COUNT(*) FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.uid_foreign = f.uid) icons FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product IN (29, 30) ORDER BY f.product, f.sorting") as $r) {
echo " #{$r['product']} \"{$r['title']}\" icons={$r['icons']}\n";
}
echo "DONE\n";
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Diagnose (2026-11-10): state of the 20 product overview pages before the
* "Enriched" workbook import, plus product slugs / solution pages needed for
* banner, icon and solution-card mapping. READ-ONLY, plain PDO.
*
* php migrations/check_overview_pages_20261110.php > migrations/check_overview_out.txt
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$slugs = ['avedia', 'ez-tv', 'x-player-end-points', 'x-view-integrated-displays', 'aetria', 'operator-workstations',
'appliances', 'modular-systems', 'rugged-appliances', 'avedia-modular-system', 'channellink-ip-gateway',
'prism-transcoder', 'aligo', 'vsn-series-video-wall-controllers', 'video-wall-management-software',
'vms-plugins-and-integrations', 'x-series-multi-display-processors', 'image-graphics-cards',
'vision-video-capture-cards', 'activesqx'];
$ce = $pdo->prepare('SELECT uid, colPos, CType, header, header_layout, vitec_layout_variant, vitec_primary_cta, vitec_primary_cta_label, tx_container_parent, sorting, hidden, LENGTH(bodytext) blen, LEFT(bodytext, 90) bshort FROM tt_content WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY colPos, sorting');
$kids = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_container_parent = ? AND CType = 'vitec_card'");
foreach ($slugs as $slug) {
$p = $pdo->prepare('SELECT uid, title, nav_title, layout, seo_title, description, keywords, abstract FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
$p->execute(['/products/' . $slug]);
$page = $p->fetch();
echo "\n=== /products/$slug ";
if (!$page) { echo "NOT FOUND\n"; continue; }
printf("#%d \"%s\" nav=\"%s\" layout=%s\n seo_title=%s | desc=%s | kw=%s\n", $page['uid'], $page['title'], $page['nav_title'], $page['layout'], $page['seo_title'], mb_substr((string)$page['description'], 0, 80), $page['keywords']);
$ce->execute([$page['uid']]);
foreach ($ce->fetchAll() as $r) {
$n = '';
if (str_starts_with($r['CType'], 'vitec_cols')) { $kids->execute([$r['uid']]); $n = ' cards=' . $kids->fetchColumn(); }
if ((int)$r['tx_container_parent'] > 0 && $r['CType'] === 'vitec_card') { continue; }
printf(" col%-3s s%-6d #%-6d %-22s %-12s %-10s \"%s\" len=%d%s %s%s\n", $r['colPos'], $r['sorting'], $r['uid'], $r['CType'], $r['vitec_layout_variant'], $r['vitec_primary_cta_label'], $r['header'], $r['blen'], $n, (int)$r['tx_container_parent'] > 0 ? ' IN#' . $r['tx_container_parent'] : '', $r['hidden'] ? ' HIDDEN' : '');
if ($r['blen'] > 0 && !str_starts_with($r['CType'], 'vitec_cols')) { echo " > " . str_replace("\n", ' ', (string)$r['bshort']) . "\n"; }
}
}
echo "\n=== products (slug | title) matching banner/icon deliveries ===\n";
foreach ($pdo->query("SELECT uid, slug, title, hidden FROM tx_vitec_domain_model_product WHERE deleted = 0 AND (slug LIKE '%mges%' OR slug LIKE '%ace%' OR slug LIKE '%chassis%' OR slug LIKE '%prism%' OR slug LIKE '%diamond%' OR slug LIKE '%avedia%') ORDER BY slug") as $r) {
printf(" #%-4d %-45s %s%s\n", $r['uid'], $r['slug'], $r['title'], $r['hidden'] ? ' (hidden)' : '');
}
echo "\n=== pages under /solutions (2 levels) ===\n";
$root = $pdo->query("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'")->fetchColumn();
$q = $pdo->prepare('SELECT uid, title, slug, doktype, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
$q->execute([$root]);
foreach ($q->fetchAll() as $g) {
printf(" #%-4d %-55s %s%s\n", $g['uid'], $g['title'], $g['slug'], $g['hidden'] ? ' (hidden)' : '');
$q2 = $pdo->prepare('SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
$q2->execute([$g['uid']]);
foreach ($q2->fetchAll() as $s) {
printf(" #%-4d %-55s %s%s\n", $s['uid'], $s['title'], $s['slug'], $s['hidden'] ? ' (hidden)' : '');
}
}
echo "\n=== products with feature cards (count) for prism/sff/avedia chassis ===\n";
foreach ($pdo->query("SELECT p.uid, p.slug, (SELECT COUNT(*) FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product = p.uid) n FROM tx_vitec_domain_model_product p WHERE p.deleted = 0 AND (p.slug LIKE '%prism%' OR p.slug LIKE '%chassis%')") as $r) {
printf(" #%-4d %-45s features=%d\n", $r['uid'], $r['slug'], $r['n']);
}
echo "\nDONE\n";
+296
View File
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
/**
* Fixes from the second Seobility crawl of 2026-11-10 (after the duplicate clean-up).
*
* A. Card CTAs pointing at hidden/deleted product URLs (internal 301 hops):
* ArtioSign/ArtioView cards -> /products/aetria (and the two redirects
* follow), X-View self-link cleared, chassis card -> surviving product.
* B. News shells still carrying the InfoComm import placeholder text:
* #6/#7/#5 are duplicates of real articles (#17/#18/#16) -> deleted + 301;
* #4 (EKOM) gets its text from the old /en/news page. #551 is a byte-
* identical copy of #550 under a title that does not match the text -> 301.
* C. Three typos.
* D. Dead vitec.com links in old articles: "https://www.vitec.com/&nbsp;",
* trailing-dot host, /industries, /green-initiative, two dead PDFs ->
* internal targets.
* E. SEO titles for the short top-level pages (/solutions "Solutions | VITEC" etc.).
* F. Meta descriptions for the nine category pages whose generated text was
* under 60 characters ("VITEC Avedia: Avedia Platform.").
*
* Dry run: php migrations/crawl2_fixes_20261110.php
* Write: php migrations/crawl2_fixes_20261110.php --apply (--only=A,B / --skip=F)
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$only = []; $skip = [];
foreach ($argv as $arg) {
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
}
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$purgePaths = [];
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
// --- helpers -----------------------------------------------------------------
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$addRedirect = static function (string $source, string $target) use ($pdo, $hasRedirect, $insertRedirect, $apply, &$purgePaths): void {
$purgePaths[$source] = true;
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
if ($existing['target'] === $target) { return; }
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$existing['uid']]); }
return;
}
echo " redirect $source -> $target\n";
if ($apply) { $insertRedirect->execute([$source, $target]); }
};
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
$st->execute([$oldTarget]);
foreach ($st->fetchAll() as $r) {
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]); }
}
};
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
echo " loeschen $table #$uid\n";
if ($apply) {
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
}
};
$newsRow = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, title, path_segment, type, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
$updateField = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
};
// Replace patterns in a text field of every matching row; prints each change.
$replaceIn = static function (string $table, string $field, array $pairs, string $label) use ($pdo, $apply, $now, &$purgePaths): int {
// pair = [needle, replacement, isRegex, likeNeedle (plain text for the SQL prefilter; required for regex pairs)]
$like = implode(' OR ', array_fill(0, count($pairs), "$field LIKE ?"));
$params = array_map(fn($p) => '%' . ($p[3] ?? $p[0]) . '%', $pairs);
$extra = $table === 'tx_news_domain_model_news' ? ', path_segment AS ref' : ($table === 'tt_content' ? ', pid AS ref' : ', uid AS ref');
$st = $pdo->prepare("SELECT uid, $field AS v $extra FROM $table WHERE deleted = 0 AND ($like)");
$st->execute($params);
$n = 0;
foreach ($st->fetchAll() as $r) {
$new = (string)$r['v'];
foreach ($pairs as [$needle, $replacement, $isRegex]) {
$new = $isRegex ? (string)preg_replace($needle, $replacement, $new) : str_replace($needle, $replacement, $new);
}
if ($new === (string)$r['v']) { continue; }
$n++;
echo " $label: $table #{$r['uid']} ({$r['ref']})\n";
if ($table === 'tx_news_domain_model_news') { $purgePaths['/news/article/' . $r['ref']] = true; }
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]); }
}
return $n;
};
// --- A. card CTAs onto hidden/deleted products -----------------------------------
if ($runs('A')) {
echo "\nA. Karten-Buttons auf versteckte/geloeschte Produkt-URLs\n";
$ctas = [
11744 => ['/product/artiosign', '/products/aetria'],
11745 => ['/product/artioview', '/products/aetria'],
11649 => ['/product/artiosign', '/products/aetria'],
11650 => ['/product/artioview', '/products/aetria'],
12179 => ['/product/avedia-modular-chassis-c1101-c1103-c1210-1', '/product/avedia-modular-chassis-c1101-c1103-c1210'],
];
$ce = $pdo->prepare("SELECT uid, pid, header, vitec_primary_cta, vitec_primary_cta_label FROM tt_content WHERE deleted = 0 AND uid = ?");
foreach ($ctas as $uid => [$old, $new]) {
$ce->execute([$uid]); $c = $ce->fetch();
if (!$c) { continue; }
if (trim((string)$c['vitec_primary_cta']) !== $old) { echo " CE #$uid: CTA ist '{$c['vitec_primary_cta']}', erwartet '$old' - uebersprungen\n"; continue; }
echo " CE #$uid (Seite {$c['pid']}, \"{$c['header']}\"): $old -> $new\n";
$updateField('tt_content', $uid, 'vitec_primary_cta', $new);
$pageSlug = $pdo->query("SELECT slug FROM pages WHERE uid = {$c['pid']}")->fetchColumn();
if ($pageSlug) { $purgePaths[$pageSlug] = true; }
}
// X-View card links to the page it sits on: no button.
$ce->execute([12133]); $c = $ce->fetch();
if ($c && trim((string)$c['vitec_primary_cta']) === '/product/x-view-integrated-displays') {
echo " CE #12133 (\"{$c['header']}\"): Selbst-Link entfernt (Button weg)\n";
$updateField('tt_content', 12133, 'vitec_primary_cta', '');
$updateField('tt_content', 12133, 'vitec_primary_cta_label', '');
$purgePaths['/products/x-view-integrated-displays'] = true;
}
// The two product redirects follow the cards.
$addRedirect('/product/artiosign', '/products/aetria');
$addRedirect('/product/artioview', '/products/aetria');
}
// --- B. placeholder news shells -----------------------------------------------------
if ($runs('B')) {
echo "\nB. Platzhalter-News (InfoComm-Importtext)\n";
$placeholderMarker = 'VITEC will demonstrate its latest enterprise video technologies at InfoComm 2026';
$isPlaceholder = static function (int $uid) use ($pdo, $placeholderMarker): bool {
$b = (string)$pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = $uid")->fetchColumn();
return strlen($b) === 1308 && str_contains($b, $placeholderMarker);
};
// duplicate shell => real article
foreach ([6 => 17, 7 => 18, 5 => 16] as $shell => $real) {
$s = $newsRow($shell); $r = $newsRow($real);
if (!$s || !$r) { echo " #$shell/#$real fehlt, uebersprungen\n"; continue; }
if (!$isPlaceholder($shell)) { echo " #$shell ist kein Platzhalter mehr, uebersprungen\n"; continue; }
echo " #$shell \"{$s['title']}\" (Platzhalter) -> #$real \"{$r['title']}\" ({$r['bl']} Zeichen)\n";
$softDelete('tx_news_domain_model_news', $shell);
$addRedirect('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
$retarget('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
$purgePaths['/news/article/' . $r['path_segment']] = true;
}
// EKOM: text lives on the old page
$s = $newsRow(4);
if ($s && $isPlaceholder(4)) {
$page = $pdo->query("SELECT uid, slug FROM pages WHERE deleted = 0 AND slug LIKE '%/news/vitec-partners-with-ekom%' ORDER BY uid LIMIT 1")->fetch();
$html = '';
if ($page) {
foreach ($pdo->query("SELECT CType, bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$page['uid']} AND CType IN ('text','textmedia','html') AND bodytext <> '' ORDER BY sorting") as $c) {
$part = (string)$c['bodytext'];
if (str_contains($part, 'vitec.com/&nbsp;')) { $part = str_replace('https://www.vitec.com/&nbsp;', '<a href="/">www.vitec.com</a>', $part); }
$html .= $part . "\n";
}
}
$len = mb_strlen($plain($html));
if ($len >= 500) {
echo " #4 \"{$s['title']}\": Text von Seite #{$page['uid']} ({$page['slug']}) uebernommen, $len Zeichen\n";
$updateField('tx_news_domain_model_news', 4, 'bodytext', trim($html));
$purgePaths['/news/article/' . $s['path_segment']] = true;
} else {
echo " #4: keine alte Seite mit Text gefunden ($len Zeichen) - bleibt\n";
}
}
// byte-identical Datapath pair: keep the one whose title matches the text
$a = $newsRow(551); $b = $newsRow(550);
if ($a && $b) {
$ba = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 551")->fetchColumn();
$bb = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 550")->fetchColumn();
if ($ba === $bb) {
echo " #551 \"{$a['title']}\" = Textkopie von #550 \"{$b['title']}\" -> weg\n";
$softDelete('tx_news_domain_model_news', 551);
$addRedirect('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
$retarget('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
}
}
}
// --- C. typos ------------------------------------------------------------------------
if ($runs('C')) {
echo "\nC. Tippfehler\n";
$replaceIn('tx_news_domain_model_news', 'bodytext', [
['/\bsponser\b/', 'sponsor', true, 'sponser'],
['/\bSponser\b/', 'Sponsor', true, 'Sponser'],
['/\breconnaisance\b/', 'reconnaissance', true, 'reconnaisance'],
['/\bReconnaisance\b/', 'Reconnaissance', true, 'Reconnaisance'],
['/\bauxillary\b/', 'auxiliary', true, 'auxillary'],
['/\bAuxillary\b/', 'Auxiliary', true, 'Auxillary'],
], 'Tippfehler');
}
// --- D. dead vitec.com links -----------------------------------------------------------
if ($runs('D')) {
echo "\nD. Tote vitec.com-Links\n";
$pairs = [
['https://www.vitec.com/&nbsp;', '<a href="/">www.vitec.com</a>', false],
['~https?://www\.vitec\.com\.(?=[/"#\s<])/?~', '/', true, 'www.vitec.com.'],
['https://www.vitec.com/green-initiative', '/insights/sustainability', false],
['https://www.vitec.com/industries/military/government', '/markets/defense-and-security', false],
['https://www.vitec.com/industries', '/markets', false],
['~https?://www\.vitec\.com/fileadmin/downloads/Company_Downloads/Use_Cases/Northwestern_University_USE_CASE_WebVersion\.pdf~', '/success-stories/northwestern-university', true, 'Northwestern_University_USE_CASE'],
['~https?://datapathdocuments\.co\.uk/wp-content/uploads/Datapath-Fx4_datasheet_EN\.pdf~', '/product/fx4', true, 'Datapath-Fx4_datasheet'],
];
$n = $replaceIn('tx_news_domain_model_news', 'bodytext', $pairs, 'Link');
$n += $replaceIn('tt_content', 'bodytext', $pairs, 'Link');
echo " $n Datensaetze\n";
}
// --- E. SEO titles for short top-level pages -------------------------------------------
if ($runs('E')) {
echo "\nE. Seitentitel der Hauptseiten\n";
$titles = [
'/solutions' => 'Video Solutions for Control Rooms, Streaming & Signage',
'/products' => 'Products: IPTV, Video Streaming, Video Walls & Capture',
'/support' => 'Support, Training & Product Resources',
'/datasheets' => 'Datasheets: Encoders, IPTV, Video Walls & Capture Cards',
'/contact' => 'Contact VITEC: Sales, Support & Offices Worldwide',
'/contact/locations' => 'VITEC Offices & Locations Worldwide',
'/success-stories' => 'Success Stories from Broadcast, Defense & Enterprise',
];
$st = $pdo->prepare("SELECT uid, title, seo_title FROM pages WHERE deleted = 0 AND slug = ?");
foreach ($titles as $slug => $seo) {
$st->execute([$slug]); $p = $st->fetch();
if (!$p) { continue; }
if ($p['seo_title'] === $seo) { continue; }
printf(" %-20s \"%s\" -> \"%s\"\n", $slug, $p['seo_title'], $seo);
$updateField('pages', (int)$p['uid'], 'seo_title', $seo);
$purgePaths[$slug] = true;
}
}
// --- F. descriptions for the thinnest category pages -------------------------------------
if ($runs('F')) {
echo "\nF. Meta-Descriptions der kuerzesten Kategorieseiten\n";
$desc = [
'/products/ez-tv' => 'EZ TV Platform by VITEC: enterprise IPTV and digital signage for live TV, video distribution and targeted content across screens and devices.',
'/products/activesqx' => 'ActiveSQX2 by VITEC and Datapath: IP stream decoding card that brings network video sources onto video walls. Specifications, features and datasheets.',
'/products/avedia' => 'Avedia Platform by VITEC: one platform for IP video, digital signage and interactive experiences across enterprise networks.',
'/products/rf-gateway' => 'VITEC RF gateways for the Avedia Modular System: receive satellite, cable and terrestrial TV and distribute it as IPTV across the network.',
'/products/prism-transcoder' => 'PRISM and PRISM SFF by VITEC: broadcast-grade video transcoders for converting, scaling and re-streaming live video, including a compact small-form-factor model.',
'/products/channellink-ip-gateway' => 'ChannelLink IP Gateway by VITEC: brings IP video streams into IPTV and digital signage networks, with transcoding for reliable delivery to every screen.',
'/products/aligo' => 'Aligo by VITEC: AV over IP and KVM encoders, receivers and workstations (QTX100, TX100, RX100, TX100 SQX) for control rooms and multi-display environments.',
'/products/vms-plugins-and-integrations' => 'VMS plugins and integrations for VITEC and Datapath video walls: bring Genetec and Milestone video management systems onto operator displays.',
'/products/image-graphics-cards' => 'Datapath Image graphics cards from VITEC (Image 2K, Image 4K, IQS4): multi-output graphics for video walls and multi-display workstations.',
];
$st = $pdo->prepare("SELECT uid, description FROM pages WHERE deleted = 0 AND slug = ?");
foreach ($desc as $slug => $d) {
$st->execute([$slug]); $p = $st->fetch();
if (!$p || $p['description'] === $d) { continue; }
printf(" %-42s (%d -> %d Zeichen)\n alt: %s\n neu: %s\n", $slug, strlen((string)$p['description']), strlen($d), $p['description'], $d);
$updateField('pages', (int)$p['uid'], 'description', $d);
$purgePaths[$slug] = true;
}
}
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
echo $apply ? "\nFERTIG.\n" : "\n(Trockenlauf, nichts geschrieben)\n";
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
/**
* Duplicate clean-up after the Seobility crawl of 2026-11-10.
*
* A. Product copy "avedia-modular-chassis-c1101-c1103-c1210-1" (empty) -> deleted, 301 to the original.
* B. News duplicates. Where the "-1/-2" copy carries the real text and the
* base record is an empty shell or an import placeholder, the shell is
* deleted and the copy takes over the clean slug (301 from the "-1" URL).
* Records without any content anywhere (#9/#229 global events, #42 DSEI
* 2023 pointer) are deleted and their URLs sent to the news overview.
* C. Records that are NOT duplicates but carry a "-1/-2" slug from the
* import: slug rebuilt from the title, 301 from the old URL.
* D. /about (page 7): only an office list, superseded by /insights/who-we-are
* and /about/locations -> hidden, 301, two links re-pointed.
* E. Stories with copied teaser / SEO description (#11 UAE University,
* #30 Kansas City Royals, #35 Minnesota United): texts rebuilt from the
* story's own first paragraph.
*
* Dry run: php migrations/dedupe_20261110.php
* Write: php migrations/dedupe_20261110.php --apply
* Sections: --only=A,B or --skip=D,E
*
* Deletions are TYPO3 soft deletes (deleted = 1, recoverable via the recycler).
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$only = []; $skip = [];
foreach ($argv as $arg) {
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
}
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$purgePaths = [];
// --- helpers -----------------------------------------------------------------
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$updateRedirectBySource = $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?");
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $updateRedirectBySource, $apply, &$purgePaths): void {
$purgePaths[$source] = true;
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
if ($existing['target'] === $target) { return; }
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
if ($apply) { $updateRedirectBySource->execute([$target, (int)$existing['uid']]); }
return;
}
echo " redirect $source -> $target\n";
if ($apply) { $insertRedirect->execute([$source, $target]); }
};
// Redirects whose TARGET is the URL that goes away get the new target (no 301 chains).
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
$st->execute([$oldTarget]);
foreach ($st->fetchAll() as $r) {
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
if ($apply) {
$pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]);
}
}
};
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
echo " loeschen $table #$uid\n";
if ($apply) {
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
}
};
$newsRow = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, title, path_segment, type, hidden, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
$setSegment = static function (int $uid, string $segment) use ($pdo, $apply, $now): void {
echo " news #$uid slug -> $segment\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$segment, $now, $uid]);
}
};
$slugify = static function (string $t): string {
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT', $t) ?: $t), '-'));
return $t;
};
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
$cut = static function (string $s, int $max): string {
if (mb_strlen($s) <= $max) { return $s; }
$s = mb_substr($s, 0, $max);
$s = preg_replace('/\s+\S*$/u', '', $s) ?? $s;
return rtrim($s, ' ,;:-') . '…';
};
$newsOverview = '/insights/news';
// --- A. product copy -----------------------------------------------------------
if ($runs('A')) {
echo "\nA. Produkt-Kopie\n";
$st = $pdo->query("SELECT uid, slug, LENGTH(description) dl FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug IN ('avedia-modular-chassis-c1101-c1103-c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1')");
$p = [];
foreach ($st as $r) { $p[$r['slug']] = $r; }
$copy = $p['avedia-modular-chassis-c1101-c1103-c1210-1'] ?? null;
$base = $p['avedia-modular-chassis-c1101-c1103-c1210'] ?? null;
if ($copy && $base && (int)$copy['dl'] === 0) {
echo " Produkt #{$copy['uid']} (leer) -> #{$base['uid']}\n";
$softDelete('tx_vitec_domain_model_product', (int)$copy['uid']);
$addRedirect('/product/' . $copy['slug'], '/product/' . $base['slug']);
$purgePaths['/product/' . $base['slug']] = true;
} else {
echo " nichts zu tun\n";
}
}
// --- B. news duplicates ----------------------------------------------------------
if ($runs('B')) {
echo "\nB. News-Dubletten\n";
// [keep uid, clean segment, [uids to delete], [old segments to redirect]]
$takeovers = [
[12, 'vitec-to-showcase-expanding-enterprise-iptv-and-digital-signage-innovations-at-infocomm-2026', [1]],
[13, 'vitec-and-lg-electronics-to-deliver-large-scale-iptv-and-digital-signage-over-next-generation-private-5g-networks', [2]],
[14, 'vitec-to-launch-prism-sff-its-most-compact-transcoder-at-eurosatory-2026', [3]],
[258, 'ise-2026-a-milestone-for-vitec-and-datapath', [10, 230]],
[21, 'vitec-acquires-datapath-ltd-to-expand-video-capabilities', [263]],
[231, 'the-impact-of-remote-work-on-security', [344]],
];
foreach ($takeovers as [$keepUid, $clean, $deleteUids]) {
$keep = $newsRow($keepUid);
if ($keep === null) { echo " #$keepUid fehlt, uebersprungen\n"; continue; }
echo " behalten #$keepUid ({$keep['bl']} Zeichen) als /news/article/$clean | {$keep['title']}\n";
foreach ($deleteUids as $d) {
$row = $newsRow($d);
if ($row === null) { echo " #$d schon weg\n"; continue; }
echo " weg: #$d /news/article/{$row['path_segment']} (type {$row['type']}, {$row['bl']} Zeichen)\n";
$softDelete('tx_news_domain_model_news', $d);
if ($row['path_segment'] !== $clean) {
$addRedirect('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
$retarget('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
}
}
if ($keep['path_segment'] !== $clean) {
$old = $keep['path_segment'];
$setSegment($keepUid, $clean);
$addRedirect('/news/article/' . $old, '/news/article/' . $clean);
$retarget('/news/article/' . $old, '/news/article/' . $clean);
}
$purgePaths['/news/article/' . $clean] = true;
}
// Records with no content anywhere.
echo " ohne Inhalt (Record + alte Seite leer):\n";
foreach ([9, 229] as $d) {
$row = $newsRow($d);
if ($row === null) { continue; }
echo " weg: #$d /news/article/{$row['path_segment']} | {$row['title']}\n";
$softDelete('tx_news_domain_model_news', $d);
$addRedirect('/news/article/' . $row['path_segment'], $newsOverview);
$retarget('/news/article/' . $row['path_segment'], $newsOverview);
}
$row = $newsRow(42);
if ($row !== null) {
$alt = $pdo->query("SELECT path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND uid <> 42 AND type = 0 AND path_segment LIKE '%dsei-2023%' LIMIT 1")->fetchColumn();
$target = $alt ? '/news/article/' . $alt : $newsOverview;
echo " weg: #42 /news/article/{$row['path_segment']} (Zeiger auf leere Seite 321) -> $target\n";
$softDelete('tx_news_domain_model_news', 42);
$addRedirect('/news/article/' . $row['path_segment'], $target);
$retarget('/news/article/' . $row['path_segment'], $target);
}
}
// --- C. wrong "-N" slugs on distinct articles --------------------------------------
if ($runs('C')) {
echo "\nC. Eigene Artikel mit Import-Suffix im Slug\n";
foreach ([
435 => 'wallcontrol-10-scoops-installation-product-awards-crown',
334 => '2023-end-of-year-message-from-ceo-bjorn-krylander',
376 => '2022-end-of-year-message-from-ceo-bjorn-krylander',
] as $uid => $segment) {
$row = $newsRow($uid);
if ($row === null || $row['path_segment'] === $segment) { continue; }
echo " #$uid | {$row['title']}\n";
$old = $row['path_segment'];
$setSegment($uid, $segment);
$addRedirect('/news/article/' . $old, '/news/article/' . $segment);
$retarget('/news/article/' . $old, '/news/article/' . $segment);
$purgePaths['/news/article/' . $segment] = true;
}
}
// --- D. /about ------------------------------------------------------------------------
if ($runs('D')) {
echo "\nD. /about (Seite 7) -> /insights/who-we-are\n";
$page = $pdo->query("SELECT uid, hidden, slug FROM pages WHERE uid = 7 AND deleted = 0")->fetch();
if ($page && (int)$page['hidden'] === 0) {
echo " Seite 7 verstecken\n";
if ($apply) { $pdo->exec("UPDATE pages SET hidden = 1, tstamp = $now WHERE uid = 7"); }
}
$addRedirect('/about', '/insights/who-we-are');
$addRedirect('/about/', '/insights/who-we-are');
foreach ($pdo->query("SELECT uid, pid, bodytext, vitec_button_link, header_link FROM tt_content WHERE deleted = 0 AND uid IN (117, 10291)") as $c) {
$changes = [];
foreach (['bodytext', 'vitec_button_link', 'header_link'] as $f) {
$v = (string)($c[$f] ?? '');
$n = (string)preg_replace('/t3:\/\/page\?uid=7(?![0-9])/', 't3://page?uid=550', $v);
$n = str_replace(['"/about"', '"/about/"'], '"/insights/who-we-are"', $n);
if (in_array($v, ['/about', '/about/', '7'], true)) { $n = $v === '7' ? '550' : 't3://page?uid=550'; }
if ($n !== $v) { $changes[$f] = $n; }
elseif ($f !== 'bodytext' && $v !== '') { echo " CE #{$c['uid']} $f = $v\n"; }
}
if ($changes) {
echo " CE #{$c['uid']} (Seite {$c['pid']}): " . implode(', ', array_keys($changes)) . " -> who-we-are\n";
if ($apply) {
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($changes)));
$pdo->prepare("UPDATE tt_content SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($changes), (int)$c['uid']]);
}
} else {
echo " CE #{$c['uid']}: kein /about-Link gefunden (manuell pruefen)\n";
}
}
$purgePaths['/'] = true;
}
// --- E. story teasers / descriptions copied from the wrong story -----------------------
if ($runs('E')) {
echo "\nE. Story-Teaser/-Beschreibungen aus dem eigenen Text\n";
$firstPara = static function (int $usecase) use ($pdo, $plain): string {
$st = $pdo->prepare("SELECT bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_vitec_usecase_content = ? AND bodytext <> '' ORDER BY sorting");
$st->execute([$usecase]);
$fallback = '';
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $html) {
$html = (string)preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', (string)$html);
$parts = preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $html, $m) ? $m[1] : [$html];
foreach ($parts as $p) {
$t = $plain($p);
if (mb_strlen($t) < 80 || str_contains($t, 'document.') || str_contains($t, 'function (')) { continue; }
// Prefer a real sentence over a title-case sub-headline.
if (mb_strlen($t) >= 120 && preg_match('/[.!?]$/u', $t) === 1) { return $t; }
if ($fallback === '') { $fallback = $t; }
}
}
return $fallback;
};
// Texts derived from each story's own (correct) teaser or headline; the
// automatic first-paragraph pick was tried and read badly.
$texts = [
11 => ['seo_description' => 'VITEC IPTV and digital signage power streaming and next-generation learning at United Arab Emirates University. Read the success story.'],
30 => ['teaser' => 'Reliable, high-quality, low-latency streams and digital signage tools: the Kansas City Royals improve fan engagement with VITEC’s IPTV platform.'],
35 => [
'teaser' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
'seo_description' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
],
];
foreach ($texts as $uid => $values) {
$st = $pdo->prepare("SELECT uid, title, teaser, seo_description FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$row = $st->fetch();
if (!$row) { continue; }
echo " #$uid {$row['title']} (erster Absatz der Story: " . $cut($firstPara($uid), 80) . ")\n";
foreach ($values as $f => $v) {
echo " $f alt: " . $cut($plain($row[$f]), 90) . "\n $f neu: $v\n";
}
if ($apply) {
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($values)));
$pdo->prepare("UPDATE tx_vitec_domain_model_usecase SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($values), $uid]);
}
}
}
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
echo $apply ? "\nFERTIG. Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 referenceindex:update\n" : "\n(Trockenlauf, nichts geschrieben)\n";
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* Data points from the frontend answers 0100-FE / 0104-FE / 0108-FE (2026-11-10).
*
* 1. /solutions: header element #1 "H1 Header" is an import placeholder -> hidden.
* 2. Home: #757 vitec_featuredcontent "Featured Product" carries lorem ipsum
* and links the empty /product shell -> hidden (placeholder).
* 3. Home: #702 vitec_card "Government" linked /markets/defense-and-security
* -> /markets/government (where the footer sends "Government").
* 4. Home: #699 text, button "Learn more" -> "Explore our solutions".
* 5. Story CDC: <h4>Product Highlight</h4> under an h2 -> <h3>.
*
* Dry run: php migrations/fe_answers_fixes_20261111.php
* Write: php migrations/fe_answers_fixes_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$set = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
};
$ce = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, pid, CType, header, hidden, bodytext, vitec_primary_cta, vitec_cta FROM tt_content WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
echo "\n1./2. Platzhalter-Elemente verstecken\n";
foreach ([1 => 'H1 Header', 757 => 'Featured Product'] as $uid => $expect) {
$c = $ce($uid);
if (!$c) { echo " #$uid fehlt\n"; continue; }
if ($c['header'] !== $expect) { echo " #$uid hat Header '{$c['header']}' (erwartet '$expect') - uebersprungen\n"; continue; }
if ((int)$c['hidden'] === 1) { echo " #$uid schon versteckt\n"; continue; }
echo " #$uid {$c['CType']} \"{$c['header']}\" auf Seite {$c['pid']}: hidden = 1\n";
$set('tt_content', $uid, 'hidden', '1');
}
echo "\n3. Karte 'Government' (#702)\n";
$c = $ce(702);
if ($c && $c['vitec_primary_cta'] === 't3://page?uid=37') {
echo " #702 CTA t3://page?uid=37 (/markets/defense-and-security) -> t3://page?uid=526 (/markets/government)\n";
$set('tt_content', 702, 'vitec_primary_cta', 't3://page?uid=526');
} else {
echo " #702: CTA ist '" . ($c['vitec_primary_cta'] ?? '?') . "' - uebersprungen\n";
}
echo "\n4. Text #699: Button-Text\n";
$c = $ce(699);
if ($c && str_contains((string)$c['bodytext'], '>Learn more</a>')) {
echo " #699: 'Learn more' -> 'Explore our solutions'\n";
$set('tt_content', 699, 'bodytext', str_replace('>Learn more</a>', '>Explore our solutions</a>', (string)$c['bodytext']));
} else {
echo " #699: kein 'Learn more'-Button gefunden - uebersprungen\n";
}
echo "\n5. Story CDC: h4 'Product Highlight' -> h3\n";
$pattern = '~<h4(\s[^>]*)?>(\s*Product Highlight\s*)</h4>~i';
$n = 0;
foreach ($pdo->query("SELECT c.uid, u.slug, c.bodytext FROM tt_content c LEFT JOIN tx_vitec_domain_model_usecase u ON u.uid = c.tx_vitec_usecase_content WHERE c.deleted = 0 AND c.bodytext LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['bodytext'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " tt_content #{$r['uid']} (Story " . ($r['slug'] ?? '-') . "): $count x h4 -> h3\n";
$set('tt_content', (int)$r['uid'], 'bodytext', $new);
}
// The story text may also live on the record itself.
$cols = $pdo->query("SHOW COLUMNS FROM tx_vitec_domain_model_usecase")->fetchAll(PDO::FETCH_COLUMN);
foreach ($cols as $col) {
if (!in_array($col, ['description', 'teaser', 'subtitle'], true)) { continue; }
foreach ($pdo->query("SELECT uid, slug, $col AS v FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND $col LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['v'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " usecase #{$r['uid']} ({$r['slug']}) Feld $col: $count x h4 -> h3\n";
$set('tx_vitec_domain_model_usecase', (int)$r['uid'], $col, $new);
}
}
// Story sections live in the content-block collection table vitec_items.
foreach ($pdo->query("SELECT uid, text FROM vitec_items WHERE deleted = 0 AND text LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['text'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " vitec_items #{$r['uid']}: $count x h4 -> h3\n";
$set('vitec_items', (int)$r['uid'], 'text', $new);
}
if ($n === 0) { echo " nichts gefunden (Text liegt woanders)\n"; }
echo "\nSSR-Pfade: / /solutions /success-stories/centers-for-disease-control-cdc\n";
echo $apply ? "FERTIG.\n" : "(Trockenlauf, nichts geschrieben)\n";
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* fix_colombia_quote.php (2026-11-04, Ticket 0038-FE Punkt 3)
*
* the-national-police-of-colombia: das lose Zitat-Element #12037 traegt nur
* die ROLLE ("Director of the Engineering Department of Cosmos Sistemas
* Integrado"), waehrend der Usecase-Record quote_person gefuellt, quote_role
* aber leer hat. Fix: Element soft-loeschen (+ Inline-Zaehler), quote_role
* am Record setzen.
*
* php migrations/fix_colombia_quote.php # INSPECT (default)
* php migrations/fix_colombia_quote.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
$db['user'],
$db['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$role = 'Director of the Engineering Department of Cosmos Sistemas Integrado';
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$story = $pdo->query(
"SELECT uid, title, quote_person, quote_role, content_elements FROM tx_vitec_domain_model_usecase
WHERE deleted = 0 AND slug = 'the-national-police-of-colombia'"
)->fetch(PDO::FETCH_ASSOC);
if (!$story) {
echo "ABBRUCH: Story the-national-police-of-colombia nicht gefunden.\n";
exit(1);
}
printf(
"Story #%d \"%s\": quote_person=\"%s\" quote_role=\"%s\" content_elements=%d\n",
$story['uid'], $story['title'], $story['quote_person'], $story['quote_role'], $story['content_elements']
);
$element = $pdo->query(
'SELECT uid, pid, CType, deleted, tx_vitec_usecase_content, bodytext, header FROM tt_content WHERE uid = 12037'
)->fetch(PDO::FETCH_ASSOC);
if (!$element) {
echo "Element #12037 existiert nicht.\n";
exit(1);
}
printf(
"Element #12037: CType=%s deleted=%d usecase=%d text=\"%s\"\n",
$element['CType'], $element['deleted'], $element['tx_vitec_usecase_content'],
mb_substr(trim(strip_tags((string)$element['bodytext'] . ' ' . (string)$element['header'])), 0, 90)
);
if ((int)$element['tx_vitec_usecase_content'] !== (int)$story['uid']) {
echo "ABBRUCH: Element #12037 haengt nicht an dieser Story.\n";
exit(1);
}
if ((int)$element['deleted'] === 1) {
echo "Element ist bereits geloescht.\n";
}
if (!$apply) {
echo "\nWuerde: Element #12037 loeschen, content_elements neu zaehlen, quote_role setzen auf:\n \"$role\"\n";
exit(0);
}
if ((int)$element['deleted'] === 0) {
$pdo->prepare('UPDATE tt_content SET deleted = 1, tstamp = ? WHERE uid = 12037')->execute([time()]);
echo "Element #12037 geloescht.\n";
}
$count = (int)$pdo->query(
'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ' . (int)$story['uid']
)->fetchColumn();
$pdo->prepare('UPDATE tx_vitec_domain_model_usecase SET content_elements = ?, quote_role = ?, tstamp = ? WHERE uid = ?')
->execute([$count, $role, time(), (int)$story['uid']]);
printf("Record aktualisiert: quote_role gesetzt, content_elements = %d.\n", $count);
echo "Danach: vendor/bin/typo3 cache:flush\n";
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/**
* Fix dead old-site links inside news bodytexts (SEO follow-up 2026-11-10).
*
* The imported Datapath articles and the 17 new SEO articles carry hrefs
* from the OLD datapath.com information architecture: /datapath-products/…,
* old /markets/… market names and /video-wall-solutions/…. None of these
* paths exist on the relaunch site (404 on every page view).
*
* 1. Rewrites every known old href inside tx_news_domain_model_news
* bodytext (and teaser, defensively) onto the matching new product
* detail page (/product/<slug>), product category page
* (/products/<slug>), market or solution page.
* 2. Queues a 301 sys_redirect for every old path (with and without
* trailing slash) so external backlinks land correctly once the
* datapath.com domains point at this site.
*
* Dry run: php migrations/fix_datapath_links_20261110.php
* Write: php migrations/fix_datapath_links_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
/* Old path (no trailing slash) => new target path. */
$mapping = [
// Aetria
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-network-manager' => '/product/aetria-network-manager',
'/datapath-products/aetria-control-room-solutions/aetria-touch' => '/product/aetria-touch',
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
'/datapath-products/aetria-control-room-solutions/operator-workstations' => '/products/operator-workstations',
// AVoIP / KVM
'/datapath-products/arqa-avoip-with-kvm' => '/product/arqa',
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
'/datapath-products/video-distribution-kvm/aligo-qtx100-quad-transmitter' => '/product/qtx100',
'/datapath-products/video-distribution-kvm/aligo-rx100-receiver' => '/product/rx100',
'/datapath-products/video-distribution-kvm/aligo-tx100-transmitter' => '/product/tx100',
// Capture & graphics
'/datapath-products/graphics-products' => '/products/capture-and-graphics',
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
'/datapath-products/graphics-products/image4k' => '/product/image-4k',
'/datapath-products/sqx-ip-capture' => '/products/activesqx',
'/datapath-products/vision-range' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/capture-card-features' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/vision-range/visionrgb-e1s' => '/product/visionrgb-e1s',
'/datapath-products/video-capture-cards/vision-range/visionsd41s' => '/product/visionsd4-1s',
'/datapath-products/video-capture-cards/vision-range/visionsdi2' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/visionav-range' => '/product/visionav',
'/datapath-products/video-capture-cards/visionav-range/visionhd4' => '/product/visionhd4',
'/datapath-products/video-capture-cards/visionsc-range' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-dp2' => '/product/visionsc-dp2',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
// Multi-display / Fx4 / Hx4
'/datapath-products/multi-display-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/multi-display-controllers/datapath-fx4-sdi' => '/product/fx4-sdi',
'/datapath-products/multi-display-controllers/datapath-hx4' => '/product/hx4',
'/datapath-products/multi-display-controllers/multi-display-overview' => '/products/x-series-multi-display-processors',
'/datapath-products/multi-display-controllers/video-wall-designer-software' => '/products/video-wall-management-software',
// Video walls / VSN / WallControl
'/datapath-products/video-wall-control/industrial-wall-controllers' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/video-wall-controllers' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/vsn-11-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/vsn-9-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers' => '/products/video-wall-controllers-and-management',
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/video-wall-controllers/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-management-software' => '/products/video-wall-management-software',
'/datapath-products/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
// Accessories (no equivalent records - portfolio page)
'/datapath-products/components-accessories/components-accessories-overview/displayport-accessories' => '/products',
'/datapath-products/components-accessories/pcie-backplanes/express11-g3' => '/products',
// Old market / solution names
'/markets/airport-and-aviation' => '/markets/transport/airports',
'/markets/corporate' => '/markets/corporation',
'/markets/emergency-operation-center' => '/markets/public-safety/emergency-operation-centres',
'/markets/events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
'/markets/military-control-room' => '/markets/defense-and-security',
'/markets/network-operation-center' => '/solutions/control-room-command-centre-solutions/control-room-platforms-soc-noc-command-centres',
'/markets/retail-and-hospitality' => '/markets/hospitality',
'/markets/security-and-surveillance' => '/solutions/security-surveillance-solutions',
'/markets/traffic-management-control-centers' => '/markets/government/traffic-and-smart-mobility',
'/markets/utilities' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/retail-events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
];
/* ------------------------------------------------------------------ */
/* Sanity: every target must exist (page slug, or product slug). */
/* ------------------------------------------------------------------ */
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND slug = ? LIMIT 1");
$missing = 0;
foreach (array_unique(array_values($mapping)) as $target) {
if (str_starts_with($target, '/product/')) {
$productBySlug->execute([substr($target, strlen('/product/'))]);
if ((int)$productBySlug->fetchColumn() > 0) { continue; }
} else {
$pageBySlug->execute([$target]);
if ((int)$pageBySlug->fetchColumn() > 0) { continue; }
}
echo " !! Ziel fehlt: $target\n";
$missing++;
}
if ($missing > 0) {
echo "$missing Ziele nicht gefunden - Mapping pruefen.\n";
if ($apply) { exit("Abbruch.\n"); }
}
/* ------------------------------------------------------------------ */
/* 1. Rewrite hrefs in news bodytext/teaser */
/* ------------------------------------------------------------------ */
echo "1. Links in News-Texten ersetzen:\n";
$findNews = $pdo->prepare("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)");
$fixNews = $pdo->prepare(
"UPDATE tx_news_domain_model_news
SET bodytext = REPLACE(bodytext, ?, ?), teaser = REPLACE(teaser, ?, ?), tstamp = UNIX_TIMESTAMP()
WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)"
);
$totalHits = 0;
foreach ($mapping as $old => $new) {
// Both href variants: with and without trailing slash; plus the one
// broken absolute form (http://datapath-products/...).
$variants = ['href="' . $old . '/"', 'href="' . $old . '"', 'href="http:/' . $old . '/"'];
foreach ($variants as $needle) {
$like = '%' . $needle . '%';
$findNews->execute([$like, $like]);
$count = (int)$findNews->fetchColumn();
if ($count === 0) { continue; }
$totalHits += $count;
echo " $needle -> href=\"$new\" ($count Record(s))\n";
if ($apply) {
$fixNews->execute([$needle, 'href="' . $new . '"', $needle, 'href="' . $new . '"', $like, $like]);
}
}
}
echo " Summe Record-Treffer: $totalHits\n";
/* ------------------------------------------------------------------ */
/* 2. 301 redirects for the old paths */
/* ------------------------------------------------------------------ */
echo "2. Redirects anlegen:\n";
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$newRedirects = 0;
foreach ($mapping as $old => $new) {
foreach ([$old, $old . '/'] as $source) {
$hasRedirect->execute([$source]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$newRedirects++;
if ($apply) {
$insertRedirect->execute([$source, $new]);
}
}
}
echo " $newRedirects neue Redirects (mit + ohne Slash; bestehende unangetastet)\n";
echo $apply ? "FERTIG. Cache flushen.\n" : "DRY RUN beendet.\n";
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* Footer columns (pid 19, html elements #132-#135, #119): align the old-site
* link texts with the relaunch structure.
*
* The footer HTML still carries the old website's wording ("Venues",
* "Military", "VITEC OG", "PCIe Encoder" …) although every href points at
* the new pages since seo_fixes_20261109. This migration, per internal link:
*
* 1. sets the link text (and title attribute) to the target page's
* navigation title (nav_title, falling back to title);
* 2. drops leaf entries that only duplicate an earlier entry of the same
* column (several old items collapsed onto one new page);
* 3. drops the orphaned catch-all entries that point at the bare /markets
* or /products overview ("Healthcare", "Telemetry" - no equivalent page);
* 4. removes list containers left empty by 2./3.
*
* External links (LinkedIn, YouTube, HelpDesk) and t3:// links stay as they
* are. Headings (<h4>) stay.
*
* Dry run: php migrations/fix_footer_link_texts_20261110.php
* Write: php migrations/fix_footer_link_texts_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$pageTitle = $pdo->prepare("SELECT title, nav_title FROM pages WHERE deleted = 0 AND slug = ? LIMIT 1");
$resolveTitle = static function (string $href) use ($pageTitle): ?string {
$pageTitle->execute([$href]);
$row = $pageTitle->fetch();
if (!$row) {
return null;
}
$t = trim((string)($row['nav_title'] ?? '')) ?: trim((string)($row['title'] ?? ''));
return $t !== '' ? $t : null;
};
$dropOverviewHrefs = ['/markets', '/products'];
// Product detail pages are records, not pages.
$productTitle = $pdo->prepare("SELECT title FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$resolveAnyTitle = static function (string $href) use ($resolveTitle, $productTitle): ?string {
if (str_starts_with($href, '/product/')) {
$productTitle->execute([substr($href, strlen('/product/'))]);
$t = trim((string)($productTitle->fetchColumn() ?: ''));
return $t !== '' ? $t : null;
}
return $resolveTitle($href);
};
/*
* Editorial overrides where the mechanical "target page title" is the wrong
* wording or the earlier redirect mapping landed on a too-generic page.
* Keyed by element uid; matched by old link text (decoded) or by href.
*/
$overrides = [
133 => [
// Solutions column: "Digital Signage" is a solution, not the product category
['matchHref' => '/products/iptv-and-digital-signage', 'href' => '/solutions/digital-signage-content-delivery'],
],
134 => [
// Fx4 is a product of its own, not the whole video-wall category
['matchText' => 'Fx4 Series', 'href' => '/product/fx4', 'text' => 'Fx4'],
// the gateways/transcoding parent has its own category page
['matchText' => 'IP/RF Gateways & Transcoders', 'href' => '/products/gateways-and-transcoding'],
],
135 => [
// sits directly below the external "Log in to HelpDesk" link
['matchHref' => '/support/login-to-helpdesk', 'text' => 'HelpDesk Access'],
],
];
$elements = $pdo->query(
"SELECT uid, bodytext FROM tt_content WHERE pid = 19 AND deleted = 0 AND CType = 'html' ORDER BY colPos, sorting"
)->fetchAll();
$update = $pdo->prepare("UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
foreach ($elements as $element) {
$uid = (int)$element['uid'];
$html = (string)$element['bodytext'];
$log = [];
// --- 1. rename internal links -----------------------------------------
$seen = [];
$elementOverrides = $overrides[$uid] ?? [];
$html = preg_replace_callback(
'#<a\b([^>]*?)href="(/[^"]*)"([^>]*)>([^<]*)</a>#s',
static function (array $m) use ($resolveAnyTitle, &$log, &$seen, $elementOverrides): string {
$href = $m[2];
$oldText = trim(strip_tags($m[4]));
$oldDecoded = html_entity_decode($oldText, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$forcedText = null;
foreach ($elementOverrides as $o) {
$hit = (isset($o['matchText']) && $o['matchText'] === $oldDecoded)
|| (isset($o['matchHref']) && $o['matchHref'] === $href);
if ($hit) {
if (!empty($o['href']) && $o['href'] !== $href) {
$log[] = sprintf(' > [%s] Ziel %s -> %s', $oldText, $href, $o['href']);
$href = $o['href'];
}
$forcedText = $o['text'] ?? null;
break;
}
}
$newTitle = $forcedText ?? $resolveAnyTitle($href);
$seen[$href] = ($seen[$href] ?? 0) + 1;
if ($newTitle === null) {
$log[] = sprintf(' ? [%s] -> %s (keine Seite, unveraendert)', $oldText, $href);
return $m[0];
}
$escaped = htmlspecialchars($newTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
$attrs = $m[1] . 'href="' . $href . '"' . $m[3];
if (preg_match('/\stitle="[^"]*"/', $attrs)) {
$attrs = preg_replace('/\stitle="[^"]*"/', ' title="' . $escaped . '"', $attrs, 1);
} else {
$attrs .= ' title="' . $escaped . '"';
}
if ($oldDecoded !== $newTitle) {
$log[] = sprintf(' ~ [%s] -> [%s] (%s)', $oldText, $newTitle, $href);
}
return '<a' . $attrs . '>' . $escaped . '</a>';
},
$html
) ?? $html;
// --- 2.+3. drop duplicate leaf items and overview catch-alls -------------
// Parent items (a link followed by a nested <ul>) always stay; a leaf
// pointing at the same page as a parent or an earlier leaf is a duplicate.
$kept = [];
// Link texts are plain ([^<]*), so neither pattern can span from a parent
// link into its nested list.
preg_match_all('#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>[^<]*</a>\s*<ul>#s', $html, $parents);
foreach ($parents[1] as $parentHref) {
$kept[$parentHref] = true;
}
$html = preg_replace_callback(
'#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>([^<]*)</a>\s*</li>\s*#s',
static function (array $m) use (&$kept, &$log, $dropOverviewHrefs): string {
$href = $m[1];
$text = trim(strip_tags($m[2]));
if (in_array($href, $dropOverviewHrefs, true)) {
$log[] = sprintf(' - [%s] -> %s (Uebersichts-Platzhalter entfernt)', $text, $href);
return '';
}
if (isset($kept[$href])) {
$log[] = sprintf(' - [%s] -> %s (Dublette entfernt)', $text, $href);
return '';
}
$kept[$href] = true;
return $m[0];
},
$html
) ?? $html;
// --- 4. empty lists ------------------------------------------------------
$before = '';
while ($before !== $html) {
$before = $html;
$html = preg_replace('#<ul>\s*</ul>#s', '', $html) ?? $html;
}
$changed = $html !== (string)$element['bodytext'];
echo "\n##### tt_content #$uid " . ($changed ? '(AENDERUNGEN)' : '(unveraendert)') . "\n";
foreach ($log as $line) {
echo $line . "\n";
}
if ($changed && $apply) {
$update->execute([$html, $uid]);
echo " gespeichert\n";
}
}
echo $apply ? "\nFERTIG. Cache flushen + SSR purgen (Direkt-SQL loest keinen Auto-Purge aus).\n" : "\nDRY RUN beendet.\n";
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* fix_overview_hero_refs.php (2026-11-03)
*
* Repariert die Hero-Platzhalter-Referenzen der 20 Produkt-Übersichtsseiten:
* vitec:build-product-overviews --apply hatte die sys_file_reference-Zeilen
* mit fieldname 'hero_bgimage' angelegt - der Content-Block vitec/herosection
* hat aber prefixFields, die echte Spalte/der echte Fieldname ist
* 'vitec_hero_bgimage' (das JSON streift den Praefix nur ab). Ergebnis:
* Referenzen existieren, rendern aber nicht.
*
* Dieses Skript biegt die vorhandenen Referenzen um (fieldname-Update) und
* setzt das Zaehlfeld tt_content.vitec_hero_bgimage - KEIN Neuanlegen.
*
* php migrations/fix_overview_hero_refs.php # INSPECT (default)
* php migrations/fix_overview_hero_refs.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
$db['user'],
$db['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
// Zielspalte muss existieren, sonst stimmt die Diagnose nicht
$col = (int)$pdo->query(
"SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tt_content' AND COLUMN_NAME = 'vitec_hero_bgimage'"
)->fetchColumn();
echo 'tt_content.vitec_hero_bgimage vorhanden: ' . ($col > 0 ? 'ja' : 'NEIN') . "\n";
if ($col === 0) {
echo "ABBRUCH: Zielspalte fehlt - Feldname im Content Block pruefen.\n";
exit(1);
}
// die falsch benannten Referenzen, nur an vitec_herosection-Elementen
$refs = $pdo->query(
"SELECT r.uid AS ref_uid, r.uid_foreign AS content_uid, r.uid_local AS file_uid,
c.pid, c.CType, f.identifier
FROM sys_file_reference r
JOIN tt_content c ON c.uid = r.uid_foreign
LEFT JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'hero_bgimage'
AND c.deleted = 0 AND c.CType = 'vitec_herosection'
ORDER BY c.pid"
)->fetchAll(PDO::FETCH_ASSOC);
echo count($refs) . " Referenz(en) mit fieldname 'hero_bgimage' an vitec_herosection:\n";
foreach ($refs as $ref) {
printf(
" ref #%d -> content #%d (pid %d) file %s\n",
$ref['ref_uid'],
$ref['content_uid'],
$ref['pid'],
(string)$ref['identifier']
);
}
// Sicherheitsnetz: Elemente, die BEIDE Varianten tragen (sollte es nicht geben)
$both = $pdo->query(
"SELECT uid_foreign FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage'
AND uid_foreign IN (SELECT uid_foreign FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'hero_bgimage')"
)->fetchAll(PDO::FETCH_COLUMN);
if ($both !== []) {
echo "ACHTUNG: Elemente mit korrekter UND falscher Referenz: " . implode(', ', $both) . " - die falschen wuerden geloescht statt umbenannt.\n";
}
if (!$apply) {
echo "\nINSPECT-Ende.\n";
exit(0);
}
$renamed = 0;
$deleted = 0;
foreach ($refs as $ref) {
if (in_array((string)$ref['content_uid'], array_map('strval', $both), true)) {
$pdo->prepare('UPDATE sys_file_reference SET deleted = 1, tstamp = ? WHERE uid = ?')
->execute([time(), (int)$ref['ref_uid']]);
$deleted++;
continue;
}
$pdo->prepare("UPDATE sys_file_reference SET fieldname = 'vitec_hero_bgimage', tstamp = ? WHERE uid = ?")
->execute([time(), (int)$ref['ref_uid']]);
$renamed++;
}
// Zaehlfeld pro betroffenem Element nachziehen
$contentUids = array_values(array_unique(array_map(static fn(array $r): int => (int)$r['content_uid'], $refs)));
foreach ($contentUids as $uid) {
$count = (int)$pdo->query(
"SELECT COUNT(*) FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage' AND uid_foreign = " . $uid
)->fetchColumn();
$pdo->prepare('UPDATE tt_content SET vitec_hero_bgimage = ?, tstamp = ? WHERE uid = ?')
->execute([$count, time(), $uid]);
}
printf("\nFertig: %d Referenz(en) umbenannt, %d geloescht, %d Zaehlfeld(er) gesetzt.\n", $renamed, $deleted, count($contentUids));
echo "Danach: vendor/bin/typo3 cache:flush\n";
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* fix_why_band_position.php (2026-11-04)
*
* Verschiebt das "Why VITEC?"-Band (introparagraph + folgende icon_card-
* Elemente aus vitec:build-why-vitec-bands) auf den 7 Produkt-Uebersichts-
* seiten VOR das "Product range"-Intro. Der Band-Lauf hatte es dahinter
* einsortiert und damit die Ueberschrift von ihren Produktkarten getrennt
* (Arne, 0042-FE Abschnitt 3).
*
* Soll: Hero -> Why-VITEC-Band -> "Product range" -> Produktkarten.
*
* php migrations/fix_why_band_position.php # INSPECT (default)
* php migrations/fix_why_band_position.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
$db['user'],
$db['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$slugs = [
'/products/activesqx',
'/products/aetria',
'/products/aligo',
'/products/avedia-modular-system',
'/products/image-graphics-cards',
'/products/operator-workstations',
'/products/prism-transcoder',
];
$moved = 0;
foreach ($slugs as $slug) {
$page = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ?');
$page->execute([$slug]);
$page = $page->fetch(PDO::FETCH_ASSOC);
if ($page === false) {
echo "$slug: Seite NICHT gefunden - uebersprungen\n\n";
continue;
}
$pid = (int)$page['uid'];
$stmt = $pdo->prepare(
'SELECT uid, CType, header, sorting, hidden FROM tt_content
WHERE pid = ? AND deleted = 0 AND colPos = 0
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid'
);
$stmt->execute([$pid]);
$elements = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Band finden: "Why VITEC?"-Intro + direkt folgende vitec_card-Elemente
$bandStart = null;
foreach ($elements as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph' && trim((string)$el['header']) === 'Why VITEC?') {
$bandStart = $i;
break;
}
}
if ($bandStart === null) {
echo "$slug (pid $pid): kein 'Why VITEC?'-Intro - uebersprungen\n\n";
continue;
}
$bandEnd = $bandStart;
while (isset($elements[$bandEnd + 1]) && $elements[$bandEnd + 1]['CType'] === 'vitec_card') {
$bandEnd++;
}
// Ziel: das erste andere Intro ("Product range")
$target = null;
foreach ($elements as $i => $el) {
if ($i >= $bandStart && $i <= $bandEnd) {
continue;
}
if ($el['CType'] === 'vitec_introparagraph') {
$target = $i;
break;
}
}
if ($target === null) {
echo "$slug (pid $pid): kein zweites Intro gefunden - uebersprungen\n\n";
continue;
}
echo "$slug (pid $pid) - Band: Elemente " . ($bandEnd - $bandStart + 1)
. " (#" . $elements[$bandStart]['uid'] . "..#" . $elements[$bandEnd]['uid'] . ")"
. ", Ziel-Intro: #" . $elements[$target]['uid'] . " \"" . $elements[$target]['header'] . "\"\n";
if ($target > $bandEnd) {
echo " Band steht bereits VOR dem Ziel-Intro - nichts zu tun\n\n";
continue;
}
// neue Reihenfolge: Band raus, vor das Ziel-Intro wieder rein
$band = array_slice($elements, $bandStart, $bandEnd - $bandStart + 1);
$rest = array_merge(array_slice($elements, 0, $bandStart), array_slice($elements, $bandEnd + 1));
$newOrder = [];
foreach ($rest as $el) {
if ((int)$el['uid'] === (int)$elements[$target]['uid']) {
foreach ($band as $b) {
$newOrder[] = $b;
}
}
$newOrder[] = $el;
}
echo " Neue Reihenfolge:\n";
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
printf(" %5d #%-6d %-24s %s\n", $sorting, $el['uid'], $el['CType'], (string)$el['header']);
if ($apply) {
$pdo->prepare('UPDATE tt_content SET sorting = ? WHERE uid = ?')->execute([$sorting, (int)$el['uid']]);
}
}
$moved++;
echo "\n";
}
echo ($apply ? "FERTIG" : "WUERDE") . ": $moved Seite(n) umsortiert.\n";
if ($apply && $moved > 0) {
echo "Danach: vendor/bin/typo3 cache:flush\n";
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* The product group pages (two middle levels of the /products tree) were
* turned into folders by the editor on 2026-11-10 (decision confirmed by
* Harry 2026-11-11: intentional). Their former URLs answered 404 since
* then (Seobility crawl 3: 8 "technische Probleme"). Every folder under
* /products gets a 301 to /products; the content elements on the folders
* stay untouched.
*
* Dry run: php migrations/folder_pages_redirects_20261111.php
* Write: php migrations/folder_pages_redirects_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$target = '/products';
$paths = [];
$n = 0;
foreach ($pdo->query("SELECT uid, slug, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $p) {
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$p['uid']}")->fetchColumn();
foreach ([$p['slug'], $p['slug'] . '/'] as $source) {
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
printf(" #%-4d %-55s (%2d CEs) %s: Redirect existiert -> %s\n", $p['uid'], $source, $ce, $p['title'], $existing['target']);
continue;
}
printf(" #%-4d %-55s (%2d CEs) %s -> %s\n", $p['uid'], $source, $ce, $p['title'], $target);
$n++;
if ($apply) { $insertRedirect->execute([$source, $target]); }
}
$paths[] = $p['slug'];
// Older redirects that point AT this folder (t3://page?uid=… or its slug)
// would now end in a 404 chain: send them to /products as well.
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND (target = ? OR target = ? OR target LIKE ?)");
$st->execute(['t3://page?uid=' . $p['uid'], $p['slug'], 't3://page?uid=' . $p['uid'] . '&%']);
foreach ($st->fetchAll() as $r) {
printf(" redirect #%d %s: Ziel %s -> %s\n", $r['uid'], $r['source_path'], $r['target'], $target);
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
}
}
echo "\n$n Redirect(s) " . ($apply ? 'angelegt' : 'wuerden angelegt') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* Follow-up to folder_pages_redirects_20261111: a product folder that holds
* exactly one visible overview page sends its visitors to that page instead
* of /products (RF Gateway -> Avedia Modular System, AV over IP & KVM -> Aligo).
* Applies to the folder's own redirects and to older redirects that were
* pointed at /products in the previous step because they targeted the folder.
*
* Dry run: php migrations/folder_redirect_targets_20261111.php
* Write: php migrations/folder_redirect_targets_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
// Older redirects (sys_redirect uid) that targeted the folder before the previous step.
$legacyByFolder = [566 => [34, 124, 125], 590 => [63, 66]];
$n = 0;
foreach ($pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $folder) {
$children = $pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = {$folder['uid']}")->fetchAll();
if (count($children) !== 1) { continue; }
$target = $children[0]['slug'];
printf(" Ordner #%d %s -> einzige Seite #%d %s\n", $folder['uid'], $folder['slug'], $children[0]['uid'], $target);
$uids = $legacyByFolder[(int)$folder['uid']] ?? [];
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND target = '/products' AND (source_path = ? OR source_path = ?" . ($uids ? ' OR uid IN (' . implode(',', array_map('intval', $uids)) . ')' : '') . ")");
$st->execute([$folder['slug'], $folder['slug'] . '/']);
foreach ($st->fetchAll() as $r) {
printf(" redirect #%-4d %-55s /products -> %s\n", $r['uid'], $r['source_path'], $target);
$n++;
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
}
}
echo "\n$n Redirect(s) " . ($apply ? 'umgebogen' : 'wuerden umgebogen') . ".\n";
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* News articles that carry the same text as a success story get the story
* as canonical (field tx_news_domain_model_news.vitec_canonical_usecase,
* evaluated by VitecMetaHandler). The article stays in the news list.
*
* Pairs from the Seobility duplicate-content report of 2026-11-10.
*
* Dry run: php migrations/news_story_canonical_20261110.php
* Write: php migrations/news_story_canonical_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
// news uid => usecase (story) uid
$pairs = [
361 => 56, // Denver Sportsbook Bar & Grill
121 => 24, // European Space Agency
164 => 26, // Feature Story News
336 => 62, // Kyndryl Cyber Operations Center
388 => 74, // Leading Video Capture for the Medical Market
239 => 66, // RRS Sir David Attenborough
158 => 46, // Sacramento Kings
310 => 68, // SENTRY Video Walls – 2024 Olympic Games
290 => 72, // UK Mission Critical Control Room
389 => 59, // Intercity Istanbul Park
436 => 64, // NHS tribute onto Parliament
395 => 54, // Bahrain International Circuit
327 => 60, // ITV News Headquarters
];
$news = $pdo->prepare("SELECT uid, title, path_segment, vitec_canonical_usecase FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$story = $pdo->prepare("SELECT uid, title, slug, hidden FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
$update = $pdo->prepare("UPDATE tx_news_domain_model_news SET vitec_canonical_usecase = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
$set = 0;
$paths = [];
foreach ($pairs as $newsUid => $storyUid) {
$news->execute([$newsUid]); $n = $news->fetch();
$story->execute([$storyUid]); $s = $story->fetch();
if (!$n || !$s) { echo " #$newsUid -> #$storyUid: Datensatz fehlt, uebersprungen\n"; continue; }
if ((int)$s['hidden'] === 1) { echo " #$newsUid -> #$storyUid: Story versteckt, uebersprungen\n"; continue; }
$state = (int)$n['vitec_canonical_usecase'] === $storyUid ? 'schon gesetzt' : 'setzen';
printf(" news #%-4d %-70s -> story #%-3d /success-stories/%s [%s]\n", $newsUid, mb_substr($n['title'], 0, 70), $storyUid, $s['slug'], $state);
$paths[] = '/news/article/' . $n['path_segment'];
if ($state === 'setzen') {
$set++;
if ($apply) { $update->execute([$storyUid, $newsUid]); }
}
}
echo "\n$set Zuordnung(en) " . ($apply ? 'geschrieben' : 'wuerden geschrieben') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
@@ -0,0 +1,19 @@
#!/bin/sh
# Placeholder flag for the content checks (2026-11-13): permissions, syntax check,
# cache flush and SSR purge of the three check pages; "commit" as first argument
# commits the two services afterwards.
set -e
cd /usr/www/users/vitecevo/live
SRV=packages/vitec/Classes/Service
chmod 644 $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh
php -l $SRV/PageCheckService.php
php -l $SRV/ProductCheckService.php
if [ "$1" = "commit" ]; then
git add $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh
git commit -q -F migrations/pagecheck_placeholder_20261113.msg
git log --oneline -1
exit 0
fi
vendor/bin/typo3 cache:flush
vendor/bin/typo3 vitec:ssr-purge /markets/market-check /solutions/solution-check /products/product-check
vendor/bin/typo3 vitec:ssr-warm /markets/market-check /solutions/solution-check /products/product-check
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# vitec_pagecheck (2026-11-13): permissions, syntax check, cache flush, page setup (dry-run unless --apply).
set -e
cd /usr/www/users/vitecevo/live
EXT=packages/vitec
CFG=$EXT/Configuration
chmod 644 $EXT/Classes/Command/CreateCheckPagesCommand.php $EXT/Classes/Service/PageCheckService.php $EXT/Classes/UserFunc/PageCheckJsonRenderer.php \
$CFG/FlexForms/Pagecheck.xml $CFG/Services.yaml $CFG/Sets/Vitecset/setup.typoscript $CFG/TCA/Overrides/tt_content.php $CFG/page.tsconfig migrations/pagecheck_setup_20261113.sh
php -l $EXT/Classes/Service/PageCheckService.php
php -l $EXT/Classes/UserFunc/PageCheckJsonRenderer.php
php -l $EXT/Classes/Command/CreateCheckPagesCommand.php
php -l $CFG/TCA/Overrides/tt_content.php
vendor/bin/typo3 cache:flush
vendor/bin/typo3 vitec:pagecheck-pages "$@"
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
/**
* SEO fixes (audit 2026-11-09):
*
* 1. Clear SELF-referencing canonical_link values. The page imports wrote
* "t3://page?uid=<self>" / "<self>" into 161 pages; TYPO3 emits a
* self-canonical anyway, and the pages sitemap EXCLUDES every page with
* a canonical_link (additionalWhere) - which is why markets, solutions,
* support, contact, insights and even the home page were missing from
* sitemap.xml.
* 2. no_index = 1 for technical pages (search, megamenu, footer shells...)
* and the imported old news pages under /en/news + /news (duplicates of
* the EXT:news articles).
* 3. 301 redirects for old-site URLs onto their new equivalents - the
* footer link targets plus every old news page that matches an EXT:news
* article by slug segment.
* 4. Rewrites the old-site hrefs inside tt_content bodytext (footer html
* columns) onto the new targets, so no page carries dead internal links.
*
* Dry run: php migrations/seo_fixes_20261109.php
* Write: php migrations/seo_fixes_20261109.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
/* ------------------------------------------------------------------ */
/* 1. Self-canonicals */
/* ------------------------------------------------------------------ */
// Comparison in PHP: CONCAT() in SQL trips over mixed column collations.
$selfCanonical = [];
foreach ($pdo->query("SELECT uid, canonical_link FROM pages WHERE deleted = 0 AND canonical_link <> ''") as $row) {
$uid = (int)$row['uid'];
$link = trim((string)$row['canonical_link']);
if ($link === 't3://page?uid=' . $uid || $link === (string)$uid) {
$selfCanonical[] = $uid;
}
}
echo "1. Self-Canonicals leeren: " . count($selfCanonical) . " Seiten\n";
if ($apply && $selfCanonical !== []) {
$pdo->exec(
"UPDATE pages SET canonical_link = '', tstamp = UNIX_TIMESTAMP()
WHERE uid IN (" . implode(',', $selfCanonical) . ")"
);
}
/* ------------------------------------------------------------------ */
/* 2. no_index for technical + old news pages */
/* ------------------------------------------------------------------ */
$technicalUids = [12, 16, 19, 106, 107, 448, 450, 516, 517, 519, 521, 523, 607];
$in = implode(',', $technicalUids);
$cnt = (int)$pdo->query("SELECT COUNT(*) FROM pages WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)")->fetchColumn();
echo "2. no_index=1 setzen: $cnt Seiten (technische Seiten + alte News-Seiten unter #106)\n";
if ($apply && $cnt > 0) {
$pdo->exec("UPDATE pages SET no_index = 1, tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)");
}
/* ------------------------------------------------------------------ */
/* 3a. Old-site URL mapping (footer targets) */
/* ------------------------------------------------------------------ */
$mapping = [
'/about/careers' => '/insights/careers',
'/about/company' => '/insights/who-we-are',
'/about/events' => '/insights/events',
'/about/locations' => '/contact/locations',
'/about/news' => '/insights/news',
'/about/sustainability' => '/insights/sustainability',
'/about/who-we-are' => '/insights/who-we-are',
'/about/why-vitec' => '/insights/who-we-are',
'/downloads' => '/datasheets',
'/markets/accommodation' => '/markets/hospitality',
'/markets/broadcast' => '/markets/broadcast-and-telecoms',
'/markets/corporate' => '/markets/corporation',
'/markets/healthcare' => '/markets',
'/markets/military' => '/markets/defense-and-security',
'/markets/sports' => '/markets/sports-and-entertainment',
'/markets/venues' => '/markets/sports-and-entertainment',
'/products/encoders-decoders' => '/products/video-encoding-and-streaming',
'/products/encoders-decoders/appliances' => '/products/appliances',
'/products/encoders-decoders/modular-systems' => '/products/modular-systems',
'/products/encoders-decoders/modular-systems/avedia-encoders' => '/products/avedia-modular-system',
'/products/encoders-decoders/modular-systems/iptv-blade-system' => '/products/avedia-modular-system',
'/products/encoders-decoders/modular-systems/vitec-og' => '/products/modular-systems',
'/products/encoders-decoders/pcie-encoder' => '/products/activesqx',
'/products/encoders-decoders/rugged' => '/products/rugged-appliances',
'/products/ip/rf-gateways-transcoders' => '/products/rf-gateway',
'/products/ip/rf-gateways-transcoders/ip-gateways' => '/products/channellink-ip-gateway',
'/products/ip/rf-gateways-transcoders/prism-transcoder' => '/products/prism-transcoder',
'/products/ip/rf-gateways-transcoders/rf-gateways' => '/products/rf-gateway',
'/products/iptv-digital-signage-platform' => '/products/iptv-and-digital-signage',
'/products/iptv-digital-signage-platform/all-in-one-platform' => '/products/avedia',
'/products/iptv-digital-signage-platform/digital-signage' => '/products/iptv-and-digital-signage',
'/products/iptv-digital-signage-platform/end-points' => '/products/end-points',
'/products/iptv-digital-signage-platform/integrated-displays' => '/products/x-view-integrated-displays',
'/products/iptv-digital-signage-platform/interactive-tv-portal' => '/products/ez-tv',
'/products/iptv-digital-signage-platform/video-wall-controllers' => '/products/video-wall-controllers-and-management',
'/products/iptv-digital-signage-platform/video-wall-controllers/aligo' => '/products/aligo',
'/products/iptv-digital-signage-platform/video-wall-controllers/fx4-series' => '/products/video-wall-controllers-and-management',
'/products/iptv-digital-signage-platform/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/products/iptv-digital-signage-platform/x-player' => '/products/x-player-end-points',
'/products/services' => '/support',
'/products/services/deployment-services' => '/support/commissioning',
'/products/services/digital-signage-and-design-services' => '/support',
'/products/services/training' => '/support/training',
'/products/telemetry' => '/products',
'/solutions/contribution-remote-production' => '/solutions/video-capture-encoding-processing/contribution-remote-production',
'/solutions/iptv-distribution' => '/solutions/video-distribution-streaming/iptv-distribution',
'/solutions/situational-awareness-and-isr' => '/solutions/real-time-monitoring-situational-awareness',
'/solutions/video-streaming' => '/solutions/video-distribution-streaming',
'/support/material-compliance' => '/support/materials-compliance',
'/support/request-helpdesk-access' => '/support/login-to-helpdesk',
'/en/news' => '/insights/news',
];
// Resolve every target slug to a page uid (sanity: fail loudly on typos).
$targetUids = [];
$stmt = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$missing = 0;
foreach (array_unique(array_values($mapping)) as $target) {
$stmt->execute([$target]);
$uid = (int)$stmt->fetchColumn();
if ($uid <= 0) {
echo " !! Zielseite fehlt: $target\n";
$missing++;
continue;
}
$targetUids[$target] = $uid;
}
if ($missing > 0 && $apply) {
exit("Abbruch: $missing Zielseiten nicht gefunden - Mapping pruefen.\n");
}
echo "3a. Redirects Alt-URLs -> neue Seiten:\n";
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$newRedirects = 0;
foreach ($mapping as $old => $new) {
if (!isset($targetUids[$new])) { continue; }
$hasRedirect->execute([$old]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$newRedirects++;
if ($apply) {
$insertRedirect->execute([$old, 't3://page?uid=' . $targetUids[$new]]);
}
}
echo " $newRedirects neue Redirects (bestehende unangetastet)\n";
/* ------------------------------------------------------------------ */
/* 3b. Old imported news pages -> EXT:news articles (slug match) */
/* ------------------------------------------------------------------ */
echo "3b. Alte News-Seiten -> /news/article/<slug>:\n";
$oldNewsPages = $pdo->query(
"SELECT uid, slug FROM pages WHERE deleted = 0 AND (pid = 106 OR uid = 107) AND slug <> ''"
)->fetchAll();
$newsBySegment = $pdo->prepare(
"SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND path_segment = ? LIMIT 1"
);
$matched = 0; $unmatched = [];
foreach ($oldNewsPages as $page) {
$segment = basename((string)$page['slug']);
$newsBySegment->execute([$segment]);
$newsUid = (int)$newsBySegment->fetchColumn();
if ($newsUid <= 0) {
$unmatched[] = $page['slug'];
continue;
}
$hasRedirect->execute([$page['slug']]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$matched++;
if ($apply) {
$insertRedirect->execute([(string)$page['slug'], '/news/article/' . $segment]);
}
}
echo " $matched Redirects auf Artikel, " . count($unmatched) . " ohne Artikel-Match (bleiben als noindex-Seiten erreichbar)\n";
foreach ($unmatched as $slug) {
echo " kein Match: $slug\n";
}
/* ------------------------------------------------------------------ */
/* 4. Dead hrefs inside tt_content bodytext */
/* ------------------------------------------------------------------ */
echo "4. Alt-Links in Inhalten ersetzen:\n";
$findContent = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND bodytext LIKE ?");
$fixContent = $pdo->prepare("UPDATE tt_content SET bodytext = REPLACE(bodytext, ?, ?), tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND bodytext LIKE ?");
$replaced = 0;
foreach ($mapping as $old => $new) {
$needle = 'href="' . $old . '"';
$findContent->execute(['%' . $needle . '%']);
$count = (int)$findContent->fetchColumn();
if ($count === 0) { continue; }
echo " $needle -> href=\"$new\" ($count Element(e))\n";
$replaced += $count;
if ($apply) {
$fixContent->execute([$needle, 'href="' . $new . '"', '%' . $needle . '%']);
}
}
echo " Summe: $replaced Element-Treffer\n";
echo $apply ? "FERTIG. Cache flushen (Redirects + Seiten sind gecacht).\n" : "DRY RUN beendet.\n";
+414
View File
@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
/**
* Fixes from the Seobility crawl of 2026-11-10 (1,094 pages).
*
* 1. Placeholder SEO title "Title for search engines [seo_title]" on /solutions.
* 2. News records whose title/path_segment were saved twice in a row.
* 3. News path_segments that contain slashes (imported from old URL paths).
* 4. Typos reported by the crawler (content tables).
* 5. Second wave of dead old-site links: /resource-center, /services,
* /contact-sales, /aetria-use-cases, /video-wall-solutions, absolute
* datapath.co.uk / datapath-us.com links and tracking-parameter variants.
* 6. "http://www.vitec.com./" (trailing dot) links.
* 7. Redirects: record shells (/product, /news/article, /success-stories/
* story) onto their overviews, plus a few orphaned product URLs.
* 8. Meta description for /datasheets.
* 9. WordPress remnants inside the 17 SEO articles (lazy-load placeholder
* images, leftover script fragment, wp-/data- attributes, bare text).
* 10. News records of type "internal page" pointing at the old /en/news
* pages: their body lives only on those pages (which now redirect onto
* the empty article URLs). The page content is copied into the record,
* images become fal_media, the record becomes a regular article, and
* every old page gets its 301.
*
* Dry run: php migrations/seo_report_fixes_20261110.php
* Write: php migrations/seo_report_fixes_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$redirectsAdded = 0;
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $apply, &$redirectsAdded): void {
$hasRedirect->execute([$source]);
if ((int)$hasRedirect->fetchColumn() > 0) {
return;
}
$redirectsAdded++;
if ($apply) {
$insertRedirect->execute([$source, $target]);
}
};
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$newsBySegment = $pdo->prepare("SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment = ? LIMIT 1");
$targetExists = static function (string $target) use ($pageBySlug, $productBySlug, $newsBySegment): bool {
if (str_starts_with($target, '/product/')) {
$productBySlug->execute([substr($target, 9)]);
return (int)$productBySlug->fetchColumn() > 0;
}
if (str_starts_with($target, '/news/article/')) {
$newsBySegment->execute([substr($target, 14)]);
return (int)$newsBySegment->fetchColumn() > 0;
}
$pageBySlug->execute([$target]);
return (int)$pageBySlug->fetchColumn() > 0;
};
/* ------------------------------------------------------------------ */
echo "\n1. Platzhalter-SEO-Titel/-Beschreibungen\n";
$rows = $pdo->query("SELECT uid, slug, seo_title FROM pages WHERE deleted = 0 AND (seo_title LIKE 'Title for search engines%' OR description LIKE 'Description for search engines%')")->fetchAll();
foreach ($rows as $r) {
echo " #{$r['uid']} {$r['slug']}: seo_title/description geleert\n";
if ($apply) {
$pdo->prepare("UPDATE pages SET seo_title = IF(seo_title LIKE 'Title for search engines%', '', seo_title), description = IF(description LIKE 'Description for search engines%', '', description), tstamp = ? WHERE uid = ?")->execute([$now, $r['uid']]);
}
}
/* ------------------------------------------------------------------ */
echo "\n2. News mit verdoppeltem Titel / path_segment\n";
$halve = static function (string $s): ?string {
$len = mb_strlen($s);
if ($len < 20 || $len % 2 !== 0) { return null; }
$h = mb_substr($s, 0, intdiv($len, 2));
return $h === mb_substr($s, intdiv($len, 2)) ? $h : null;
};
foreach ($pdo->query("SELECT uid, title, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND CHAR_LENGTH(title) > 40") as $r) {
$newTitle = $halve((string)$r['title']);
if ($newTitle === null) { continue; }
$seg = (string)$r['path_segment'];
$newSeg = $seg;
// doubled slug: "a-b-ca-b-c" -> "a-b-c"
$segLen = strlen($seg);
if ($segLen % 2 === 0 && substr($seg, 0, intdiv($segLen, 2)) === substr($seg, intdiv($segLen, 2))) {
$newSeg = substr($seg, 0, intdiv($segLen, 2));
}
echo " #{$r['uid']}: Titel -> [$newTitle]" . ($newSeg !== $seg ? ", Slug -> $newSeg (+Redirect)" : '') . "\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET title = ?, path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$newTitle, $newSeg, $now, $r['uid']]);
}
if ($newSeg !== $seg) {
$addRedirect('/news/article/' . $seg, '/news/article/' . $newSeg);
}
}
/* ------------------------------------------------------------------ */
echo "\n3. News path_segment mit Slash\n";
foreach ($pdo->query("SELECT uid, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment LIKE '%/%'") as $r) {
$old = (string)$r['path_segment'];
$new = basename($old);
$newsBySegment->execute([$new]);
$taken = (int)$newsBySegment->fetchColumn();
if ($taken > 0 && $taken !== (int)$r['uid']) {
$new .= '-' . $r['uid'];
}
echo " #{$r['uid']}: $old -> $new (+Redirect)\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, $r['uid']]);
}
$addRedirect('/news/article/' . $old, '/news/article/' . $new);
}
/* ------------------------------------------------------------------ */
echo "\n4. Tippfehler\n";
$typos = [
'continously' => 'continuously', 'continous' => 'continuous', 'indpendent' => 'independent',
'aircrafts' => 'aircraft', 'sponser' => 'sponsor', 'auxillary' => 'auxiliary',
'processer' => 'processor', 'accomodation' => 'accommodation',
'Accomodation' => 'Accommodation', 'Continous' => 'Continuous',
];
$typoTargets = [
'tt_content' => ['bodytext', 'header'],
'tx_news_domain_model_news' => ['bodytext', 'teaser', 'title'],
'tx_vitec_domain_model_usecase' => ['description', 'teaser'],
];
foreach ($typos as $wrong => $right) {
foreach ($typoTargets as $table => $fields) {
foreach ($fields as $field) {
$n = (int)$pdo->query("SELECT COUNT(*) FROM $table WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'))->fetchColumn();
if ($n === 0) { continue; }
echo " $wrong -> $right: $table.$field ($n)\n";
if ($apply) {
$pdo->exec("UPDATE $table SET $field = REPLACE($field, " . $pdo->quote($wrong) . ", " . $pdo->quote($right) . "), tstamp = $now WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'));
}
}
}
}
/* ------------------------------------------------------------------ */
echo "\n5. Tote Alt-Links (zweite Welle, inkl. datapath.co.uk / utm-Varianten)\n";
$awardSeg = 'aetria-award-wins-set-the-tone-for-datapaths-best-ever-ise-week';
$newsBySegment->execute([$awardSeg]);
$awardTarget = (int)$newsBySegment->fetchColumn() > 0 ? '/news/article/' . $awardSeg : '/insights/news';
$mapping2 = [
'/resource-center/legacy-products' => '/products/capture-and-graphics',
'/resource-center/legacy-products/imagedp4' => '/products/image-graphics-cards',
'/resource-center/legacy-products/datapath-x4' => '/products/video-wall-controllers-and-management',
'/resource-center/legacy-products/datapath-dl8' => '/products/video-wall-controllers-and-management',
'/legacy-products/imagedp4-plus' => '/products/image-graphics-cards',
'/resource-center/get-support' => '/support',
'/services/premium-service-package' => '/support/support-plans',
'/services/training' => '/support/training',
'/contact-sales' => '/contact/contact-form',
'/aetria-use-cases/oil-and-gas-control-room' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/energy-trading-and-dispatch' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/cctv-for-university-campus' => '/markets/education',
'/video-wall-solutions/commercial-video-walls' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/control-rooms' => '/solutions/control-room-command-centre-solutions',
'/video-wall-solutions/projection-mapping' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
'/insights/success-stories' => '/success-stories',
'/datapath-products' => '/products',
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-command-center' => '/product/aetria-command-center',
'/datapath-products/aetria-control-room-solutions/aetria-workstation' => '/product/aetria-workstation',
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
'/datapath-products/video-wall-controllers/vsn-v3' => '/product/vsn-v3',
'/datapath-products/video-wall-controllers/vsn' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsnmicro-600' => '/product/vsnmicro-600',
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-s2' => '/product/visionsc-s2',
'/datapath-products/video-wall-management-software/wallcontrol-10-software' => '/product/wall-control-10',
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
'/datapath-products/components-accessories/rack-mounting-kits' => '/products',
'/datapath-products/sqx-ip-capture/active-sqx' => '/product/activesqx',
'/datapath-products/video-wall-control' => '/products/video-wall-controllers-and-management',
'/' . $awardSeg => $awardTarget,
];
$missing = 0;
foreach (array_unique(array_values($mapping2)) as $t) {
if (!$targetExists($t)) { echo " !! Ziel fehlt: $t\n"; $missing++; }
}
if ($missing > 0 && $apply) { exit("Abbruch: Ziele fehlen.\n"); }
$hostPrefix = '(?:https?://(?:www\.)?datapath(?:-us)?\.(?:co\.uk|com))?';
$linkTables = ['tx_news_domain_model_news' => 'bodytext', 'tt_content' => 'bodytext'];
$linkHits = 0;
foreach ($linkTables as $table => $field) {
$select = $pdo->query("SELECT uid, $field AS body FROM $table WHERE deleted = 0 AND ($field LIKE '%datapath%' OR $field LIKE '%/resource-center/%' OR $field LIKE '%/services/%' OR $field LIKE '%/contact-sales%' OR $field LIKE '%/aetria-use-cases/%' OR $field LIKE '%/video-wall-solutions%' OR $field LIKE '%/insights/success-stories%' OR $field LIKE '%/legacy-products/%' OR $field LIKE '%aetria-award-wins%')");
$update = $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?");
foreach ($select as $r) {
$body = (string)$r['body'];
$new = $body;
foreach ($mapping2 as $old => $target) {
$pattern = '#href="' . $hostPrefix . preg_quote($old, '#') . '/?(?:\?[^"]*)?"#';
$new = preg_replace($pattern, 'href="' . $target . '"', $new) ?? $new;
}
if ($new !== $body) {
$linkHits++;
echo " $table #{$r['uid']}: Links ersetzt\n";
if ($apply) { $update->execute([$new, $now, $r['uid']]); }
}
}
}
echo " Records mit ersetzten Links: $linkHits\n";
foreach ($mapping2 as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n6. http://www.vitec.com./ (Punkt-Tippfehler)\n";
$n = (int)$pdo->query("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'")->fetchColumn();
echo " $n Record(s)\n";
if ($apply && $n > 0) {
$pdo->exec("UPDATE tx_news_domain_model_news SET bodytext = REPLACE(REPLACE(bodytext, 'http://www.vitec.com./', 'https://www.vitec.com/'), 'https://www.vitec.com./', 'https://www.vitec.com/'), tstamp = $now WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'");
}
/* ------------------------------------------------------------------ */
echo "\n7. Redirects fuer Record-Shells und verwaiste Produkt-URLs\n";
foreach ([
'/product' => '/products',
'/news/article' => '/insights/news',
'/success-stories/story' => '/success-stories',
'/product/artiosign' => '/products/video-wall-management-software',
'/product/artioview' => '/products/video-wall-management-software',
'/product/x-view-integrated-displays' => '/products/x-view-integrated-displays',
] as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n8. Meta-Description /datasheets\n";
$st = $pdo->prepare("UPDATE pages SET description = ?, tstamp = ? WHERE uid = 17 AND (description IS NULL OR description = '')");
$desc = 'Download VITEC and Datapath product datasheets: encoders and decoders, IPTV and digital signage platforms, video wall controllers, capture cards and more - searchable by product and category.';
if ($apply) { $st->execute([$desc, $now]); echo " gesetzt (" . $st->rowCount() . ")\n"; } else { echo " wird gesetzt, falls leer\n"; }
/* ------------------------------------------------------------------ */
echo "\n9. WordPress-Reste in den SEO-Artikeln\n";
$seoUids = [232,234,235,236,237,238,240,241,242,243,244,245,246,247,248,249,252];
$sel = $pdo->query("SELECT uid, path_segment, bodytext FROM tx_news_domain_model_news WHERE deleted = 0 AND uid IN (" . implode(',', $seoUids) . ")");
$upd = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, tstamp = ? WHERE uid = ?");
foreach ($sel as $r) {
$b = (string)$r['bodytext'];
$o = $b;
$log = [];
$b = preg_replace('#<img[^>]*src="data:image/[^"]*"[^>]*>\s*#i', '', $b, -1, $c1) ?? $b; if ($c1) { $log[] = "$c1 Platzhalter-Bilder"; }
$b = preg_replace('#<p>[^<]*getWR360PopupSkin[^<]*</p>\s*#i', '', $b, -1, $c2) ?? $b; if ($c2) { $log[] = "Script-Rest"; }
$b = preg_replace('#\s+class="wp-[^"]*"#i', '', $b, -1, $c3) ?? $b; if ($c3) { $log[] = "$c3 wp-Klassen"; }
$b = preg_replace('#\s+data-[a-z0-9-]+="[^"]*"#i', '', $b, -1, $c4) ?? $b; if ($c4) { $log[] = "$c4 data-Attribute"; }
// bare description text between a product <h3> and the following link
$b = preg_replace('#(</h3>)\s*([^<\s][^<]{20,}?)\s*(?=<a |<h[2-4]|<p>|<ul>)#s', '$1<p>$2</p>', $b, -1, $c5) ?? $b; if ($c5) { $log[] = "$c5 nackte Textabsaetze gekapselt"; }
$b = preg_replace('#<a([^>]*)>\s*View products?\s*</a>#i', '<a$1>View products</a>', $b, -1, $c6) ?? $b; if ($c6) { $log[] = "$c6 View-products-Links normalisiert"; }
if ($b !== $o) {
echo " #{$r['uid']} {$r['path_segment']}: " . implode(', ', $log) . "\n";
if ($apply) { $upd->execute([$b, $now, $r['uid']]); }
}
}
/* ------------------------------------------------------------------ */
echo "\n10. News-Zeiger-Records (type=1) -> Inhalt der alten Seite uebernehmen\n";
$allowedTags = '<p><h2><h3><h4><ul><ol><li><strong><em><b><i><a><br><blockquote>';
$stripAttrs = static function (string $html): string {
// keep href/target on links, drop everything else (style, class, ids)
$html = preg_replace_callback('#<a\b([^>]*)>#i', static function (array $m): string {
preg_match('/href="([^"]*)"/i', $m[1], $h);
preg_match('/target="([^"]*)"/i', $m[1], $t);
return '<a' . (isset($h[1]) ? ' href="' . $h[1] . '"' : '') . (isset($t[1]) ? ' target="' . $t[1] . '"' : '') . '>';
}, $html) ?? $html;
return preg_replace('#<(p|h[2-4]|ul|ol|li|strong|em|b|i|blockquote|br)\b[^>]*>#i', '<$1>', $html) ?? $html;
};
$collectContent = null;
$collectContent = static function (int $pid, int $parent, array &$texts, array &$imageRefs) use ($pdo, &$collectContent, $allowedTags, $stripAttrs): void {
$st = $pdo->prepare("SELECT uid, CType, bodytext, header FROM tt_content WHERE pid = ? AND tx_container_parent = ? AND deleted = 0 AND hidden = 0 ORDER BY colPos, sorting");
$st->execute([$pid, $parent]);
foreach ($st->fetchAll() as $el) {
$ctype = (string)$el['CType'];
if (str_starts_with($ctype, 'vitec_container') || str_starts_with($ctype, 'vitec_cols')) {
$collectContent($pid, (int)$el['uid'], $texts, $imageRefs);
continue;
}
if (in_array($ctype, ['image', 'textpic', 'textmedia'], true)) {
$refs = $pdo->prepare("SELECT uid FROM sys_file_reference WHERE tablenames = 'tt_content' AND uid_foreign = ? AND fieldname IN ('image', 'assets', 'media') AND deleted = 0 AND hidden = 0 ORDER BY sorting_foreign");
$refs->execute([(int)$el['uid']]);
foreach ($refs->fetchAll() as $ref) { $imageRefs[] = (int)$ref['uid']; }
}
if (in_array($ctype, ['text', 'textpic', 'textmedia'], true)) {
$t = trim((string)$el['bodytext']);
if ($t !== '') { $texts[] = $stripAttrs(strip_tags($t, $allowedTags)); }
continue;
}
if ($ctype === 'html') {
$raw = (string)$el['bodytext'];
if (preg_match('#<blockquote[^>]*>(.*?)</blockquote>#is', $raw, $q)) {
$cite = '';
if (preg_match('#</blockquote>\s*<span[^>]*>(.*?)</span>#is', $raw, $s)) { $cite = trim(strip_tags($s[1])); }
$texts[] = '<blockquote><p>' . trim(strip_tags($q[1])) . '</p>' . ($cite !== '' ? '<p><em>' . $cite . '</em></p>' : '') . '</blockquote>';
continue;
}
if (stripos($raw, '<style') !== false || stripos($raw, '<script') !== false || mb_strlen(trim(strip_tags($raw))) < 200) {
continue; // layout snippets (buttons, styles)
}
$texts[] = $stripAttrs(strip_tags($raw, $allowedTags));
}
}
};
$pointerRows = $pdo->query("SELECT n.uid, n.pid, n.title, n.path_segment, n.internalurl, n.bodytext, n.fal_media FROM tx_news_domain_model_news n WHERE n.deleted = 0 AND n.type = '1'")->fetchAll();
$pageSlugs = $pdo->prepare("SELECT uid, slug, pid FROM pages WHERE uid = ? AND deleted = 0");
$maxRefSorting = $pdo->prepare("SELECT COALESCE(MAX(sorting_foreign), 0) FROM sys_file_reference WHERE tablenames = 'tx_news_domain_model_news' AND fieldname = 'fal_media' AND uid_foreign = ?");
$copyRef = $pdo->prepare(
"INSERT INTO sys_file_reference (pid, tstamp, crdate, uid_local, uid_foreign, tablenames, fieldname, sorting_foreign, title, alternative, description, crop, link)
SELECT ?, ?, ?, uid_local, ?, 'tx_news_domain_model_news', 'fal_media', ?, title, alternative, description, crop, link FROM sys_file_reference WHERE uid = ?"
);
$updNews = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, type = '0', internalurl = '', fal_media = ?, tstamp = ? WHERE uid = ?");
$updType = $pdo->prepare("UPDATE tx_news_domain_model_news SET type = '0', internalurl = '', tstamp = ? WHERE uid = ?");
$migrated = 0; $typeOnly = 0; $skipped = 0;
foreach ($pointerRows as $n) {
$pid = 0;
if (preg_match('/uid=(\d+)/', (string)$n['internalurl'], $m)) { $pid = (int)$m[1]; } elseif (ctype_digit((string)$n['internalurl'])) { $pid = (int)$n['internalurl']; }
$pageSlugs->execute([$pid]);
$page = $pageSlugs->fetch();
if (!$page || !in_array((int)$page['pid'], [106], true) && (int)$page['uid'] !== 107) {
echo " #{$n['uid']}: Ziel-Seite $pid nicht im alten News-Ast - uebersprungen\n";
$skipped++;
continue;
}
$oldSlug = (string)$page['slug'];
$newUrl = '/news/article/' . $n['path_segment'];
if (trim((string)$n['bodytext']) !== '') {
echo " #{$n['uid']}: hat eigenen Text (" . strlen((string)$n['bodytext']) . " Z.) -> nur type=0" . "\n";
$typeOnly++;
if ($apply) { $updType->execute([$now, $n['uid']]); }
$addRedirect($oldSlug, $newUrl);
continue;
}
$texts = []; $imageRefs = [];
$collectContent($pid, 0, $texts, $imageRefs);
$body = trim(implode("\n", $texts));
$plainLen = mb_strlen(trim(strip_tags($body)));
if ($plainLen < 500) {
echo " #{$n['uid']}: Seite $pid liefert nur $plainLen Zeichen Text - uebersprungen (bleibt Zeiger)\n";
$skipped++;
continue;
}
$imageRefs = array_slice(array_unique($imageRefs), 0, 3);
// Imported placeholder slugs ("default-<hash>") take the old page's slug.
$segment = (string)$n['path_segment'];
if (str_starts_with($segment, 'default-')) {
$candidate = basename($oldSlug);
$newsBySegment->execute([$candidate]);
if ((int)$newsBySegment->fetchColumn() === 0) {
$segment = $candidate;
$newUrl = '/news/article/' . $segment;
echo " #{$n['uid']}: Slug default-... -> $segment\n";
}
}
echo " #{$n['uid']} $segment: $plainLen Zeichen aus " . count($texts) . " Elementen, " . count($imageRefs) . " Bild(er) <- $oldSlug\n";
$migrated++;
if ($apply) {
if ($segment !== (string)$n['path_segment']) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ? WHERE uid = ?")->execute([$segment, $n['uid']]);
}
$sorting = 0;
$maxRefSorting->execute([$n['uid']]);
$sorting = (int)$maxRefSorting->fetchColumn();
$existing = (int)$n['fal_media'];
foreach ($imageRefs as $refUid) {
$sorting += 256;
$copyRef->execute([(int)$n['pid'], $now, $now, (int)$n['uid'], $sorting, $refUid]);
$existing++;
}
$updNews->execute([$body, $existing, $now, $n['uid']]);
}
$addRedirect($oldSlug, $newUrl);
}
echo " uebernommen: $migrated, nur type=0: $typeOnly, uebersprungen: $skipped\n";
echo "\nRedirects neu: $redirectsAdded\n";
echo $apply ? "FERTIG. cache:flush + SSR purge + warm.\n" : "DRY RUN beendet.\n";
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// Meta description for the /solutions overview (page 2) - was the TCA
// placeholder text until seo_report_fixes_20261110 cleared it. Fill-up only.
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
$s=require $root.'/config/system/settings.php';$db=$s['DB']['Connections']['Default'];
$pdo=new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',$db['host']??'localhost',$db['port']??3306,$db['dbname']??''),$db['user']??'',$db['password']??'',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
$desc='Explore VITEC video solutions for control rooms and command centres, real-time monitoring, video distribution and streaming, capture and encoding, video walls, digital signage, enterprise communications and security.';
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=2 AND (description IS NULL OR description='')");
$st->execute([$desc]);
echo "Seite 2 (/solutions): ".$st->rowCount()." Zeile(n) aktualisiert\n";
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// Setzt die fehlende Meta-Description der Success-Stories-Uebersicht (Seite 4).
// Fill-up only: eine vorhandene Description wird nicht ueberschrieben.
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
$s=require $root.'/config/system/settings.php';$db=$s['DB']['Connections']['Default'];
$pdo=new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',$db['host']??'localhost',$db['port']??3306,$db['dbname']??''),$db['user']??'',$db['password']??'',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
$desc='Discover how organizations worldwide use VITEC and Datapath video solutions: customer success stories from broadcast, defense, enterprise, sports and public safety.';
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=4 AND (description IS NULL OR description='')");
$st->execute([$desc]);
echo "Seite 4: ".$st->rowCount()." Zeile(n) aktualisiert\n";
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Old Datapath/VITEC articles link to partner sites that no longer exist
* (Seobility "Probleme mit externen Links", decision Harry 2026-11-10:
* remove the link, keep the text). The anchor is replaced by its text.
*
* Dry run: php migrations/unlink_dead_external_20261110.php
* Write: php migrations/unlink_dead_external_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("no project root\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$hosts = [
'queensawardsmagazine.com',
'samminton.com',
'eetgroup.com',
'broadcast-asia.com',
'tecnomultimedia.com',
'sentryvideowalls.com',
'exertis.co.uk',
'infocomm-sea.com',
'surveymonkey.co.uk',
'exterity.com',
'eurekanetwork.org',
];
$hostAlt = implode('|', array_map(fn($h) => preg_quote($h, '~'), $hosts));
// <a ... href="...host..." ...>text</a> -> text
$pattern = '~<a\b[^>]*\bhref="[^"]*(?:' . $hostAlt . ')[^"]*"[^>]*>(.*?)</a>~is';
$purge = [];
$total = 0;
foreach (['tx_news_domain_model_news' => 'path_segment', 'tt_content' => 'pid'] as $table => $ref) {
$like = implode(' OR ', array_map(fn($h) => "bodytext LIKE '%$h%'", $hosts));
foreach ($pdo->query("SELECT uid, $ref AS ref, bodytext FROM $table WHERE deleted = 0 AND ($like)") as $r) {
$new = (string)preg_replace_callback($pattern, static function (array $m): string {
return $m[1];
}, (string)$r['bodytext'], -1, $count);
if ($count === 0 || $new === (string)$r['bodytext']) {
echo " $table #{$r['uid']}: Host im Text, aber kein <a>-Tag erkannt (manuell pruefen)\n";
continue;
}
preg_match_all('~https?://[^"\s<]*(?:' . $hostAlt . ')[^"\s<]*~i', (string)$r['bodytext'], $urls);
printf(" %s #%-4d (%s): %d Link(s) entfernt: %s\n", $table, $r['uid'], $r['ref'], $count, implode(', ', array_unique($urls[0])));
$total += $count;
if ($table === 'tx_news_domain_model_news') { $purge[] = '/news/article/' . $r['ref']; }
if ($apply) {
$pdo->prepare("UPDATE $table SET bodytext = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]);
}
}
}
echo "\n$total Link(s) in " . count($purge) . " News" . ($apply ? ' entfernt' : ' wuerden entfernt') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $purge) . "\n";
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* why_band_unwrap_20261111.php (Ticket 0106-FE, Abschnitt "alte Seiten")
*
* Die drei 2026-10-Kategorieseiten ohne Menueplatz tragen ihre "Why VITEC?"-
* Karten noch als `vertical`-Karten IN vitec_cols_33_33_33-Containern
* (wrap-cards-grid vom 27.10.). Das Frontend baut das Icon-Band (0039-BE)
* aber nur aus `vitec_card` mit layout_variant icon_card DIREKT hinter dem
* Intro. Entscheidung Harry 2026-11-11: diese Seiten bekommen das Band auch.
*
* Umbau je Seite: Intro "Why VITEC?" finden, die direkt folgenden Container
* (nur solche, die ausschliesslich vitec_card enthalten) aufloesen - Karten
* nach colPos 0 / tx_container_parent 0 / icon_card, leere Container soft-
* loeschen, colPos 0 mit Stride 256 neu sortieren (Band direkt hinter dem
* Intro, Rest in bisheriger Folge). Direktes SQL -> Auto-Purge greift NICHT:
* danach cache:flush + vitec:ssr-purge/-warm mit den Pfaden.
*
* php migrations/why_band_unwrap_20261111.php # INSPECT (default)
* php migrations/why_band_unwrap_20261111.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$slugs = [
'/products/graphics-and-multi-display',
'/products/infrastructure-and-accessories',
'/products/video-capture',
];
$norm = static fn(string $s): string => (string)preg_replace('/[^a-z0-9]+/', '', strtolower($s));
$done = 0;
foreach ($slugs as $slug) {
$st = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
$st->execute([$slug]);
$page = $st->fetch();
if (!$page) {
echo "$slug: Seite NICHT gefunden - uebersprungen\n\n";
continue;
}
$pid = (int)$page['uid'];
$st = $pdo->prepare(
'SELECT uid, CType, header, sorting, vitec_layout_variant FROM tt_content
WHERE pid = ? AND deleted = 0 AND colPos = 0 AND sys_language_uid = 0
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid'
);
$st->execute([$pid]);
$top = $st->fetchAll();
echo "== $slug (pid $pid) \"{$page['title']}\" - " . count($top) . " Top-Level-Elemente in colPos 0\n";
// bereits umgebaut? (Band = Intro + direkt folgende Karten top-level)
$whyIdx = null;
foreach ($top as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph' && str_starts_with($norm((string)$el['header']), 'whyvitec')) {
$whyIdx = $i;
break;
}
}
if ($whyIdx === null) {
echo " kein Why-VITEC-Intro gefunden - uebersprungen\n\n";
continue;
}
$intro = $top[$whyIdx];
if (isset($top[$whyIdx + 1]) && $top[$whyIdx + 1]['CType'] === 'vitec_card') {
echo " Band liegt bereits top-level hinter Intro #{$intro['uid']} - nichts zu tun\n\n";
continue;
}
// direkt folgende Container einsammeln, solange sie nur Karten enthalten
$containers = [];
$cards = [];
for ($i = $whyIdx + 1; $i < count($top); $i++) {
$el = $top[$i];
if (!str_starts_with((string)$el['CType'], 'vitec_cols')) {
break;
}
$kids = $pdo->query(
'SELECT uid, CType, header, colPos, sorting, vitec_layout_variant FROM tt_content
WHERE deleted = 0 AND tx_container_parent = ' . (int)$el['uid'] . ' ORDER BY colPos, sorting, uid'
)->fetchAll();
$onlyCards = $kids !== [] && count(array_filter($kids, static fn(array $k): bool => $k['CType'] !== 'vitec_card')) === 0;
if (!$onlyCards) {
break;
}
$containers[] = $el;
foreach ($kids as $k) {
$cards[] = $k;
}
}
if ($cards === []) {
echo " Intro #{$intro['uid']} \"{$intro['header']}\" ohne folgende Karten-Container - uebersprungen\n\n";
continue;
}
printf(" Intro #%d \"%s\"\n", $intro['uid'], $intro['header']);
printf(" %d Container (#%s) mit %d Karte(n):\n", count($containers), implode(', #', array_column($containers, 'uid')), count($cards));
foreach ($cards as $c) {
printf(" #%-6d %-14s \"%s\"\n", $c['uid'], (string)$c['vitec_layout_variant'], (string)$c['header']);
}
// neue Reihenfolge: bis Intro, dann Karten, dann Rest ohne die Container
$containerUids = array_map('intval', array_column($containers, 'uid'));
$newOrder = [];
foreach ($top as $i => $el) {
if (in_array((int)$el['uid'], $containerUids, true)) {
continue;
}
$newOrder[] = ['uid' => (int)$el['uid'], 'label' => $el['CType'] . ' ' . $el['header']];
if ($i === $whyIdx) {
foreach ($cards as $c) {
$newOrder[] = ['uid' => (int)$c['uid'], 'label' => 'vitec_card (icon_card) ' . $c['header']];
}
}
}
echo " Neue Reihenfolge colPos 0:\n";
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
printf(" %5d #%-6d %s\n", $sorting, $el['uid'], $el['label']);
}
if ($apply) {
$pdo->beginTransaction();
$upCard = $pdo->prepare("UPDATE tt_content SET colPos = 0, tx_container_parent = 0, vitec_layout_variant = 'icon_card', tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
foreach ($cards as $c) {
$upCard->execute([(int)$c['uid']]);
}
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', $containerUids) . ')');
$upSort = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
$upSort->execute([$sorting, $el['uid']]);
}
$pdo->commit();
echo " GESCHRIEBEN\n";
}
$done++;
echo "\n";
}
echo ($apply ? 'FERTIG' : 'WUERDE') . ": $done Seite(n) umgebaut.\n";
if ($apply && $done > 0) {
echo "Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge " . implode(' ', $slugs) . " && vendor/bin/typo3 vitec:ssr-warm " . implode(' ', $slugs) . "\n";
}
@@ -43,7 +43,7 @@ return [
'1' => [ '1' => [
'showitem' => ' 'showitem' => '
--div--;' . $ll . 'tab.general, --div--;' . $ll . 'tab.general,
hidden, title, link, layout, tree_display, hidden, title, link, layout, tree_display, source_pages, source_categories,
--div--;' . $ll . 'tab.columns, --div--;' . $ll . 'tab.columns,
menu_columns, menu_columns,
--div--;' . $ll . 'tab.teasers, --div--;' . $ll . 'tab.teasers,
@@ -131,6 +131,27 @@ return [
], ],
], ],
], ],
// data-driven layouts: where the site package's listener reads its
// records from - the extension only passes the entry row on
'source_pages' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_pages',
'description' => $ll . 'source_pages.description',
'config' => [
'type' => 'group',
'allowed' => 'pages',
'size' => 3,
'maxitems' => 20,
],
],
'source_categories' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_categories',
'description' => $ll . 'source_categories.description',
'config' => [
'type' => 'category',
],
],
'menu_columns' => [ 'menu_columns' => [
'label' => $ll . 'menu_columns', 'label' => $ll . 'menu_columns',
+14 -3
View File
@@ -117,6 +117,13 @@ final class ProvideCatalog
`getEntry()` hands the listener the raw entry row (to tell several catalog `getEntry()` hands the listener the raw entry row (to tell several catalog
entries apart), `getStage()` the content stage. entries apart), `getStage()` the content stage.
**Data source fields** (catalog and spotlight entries): *Data source
folders* (`source_pages`, comma list of page uids) and *Data source
categories* (`source_categories`, sys_category MM with
`tablenames = tx_evomegamenujson_entry`, `fieldname = source_categories`).
The extension only stores them; the listener reads them from the entry row
and decides what they mean (empty = its own default).
### Layout `pagetree` ### Layout `pagetree`
A structure panel that can never age against the site: the columns mirror A structure panel that can never age against the site: the columns mirror
@@ -156,9 +163,13 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
} }
``` ```
- `news`: the three latest news records (storage pid 11, type 0 - the Solr - `news`: the three latest news records (type 0 - the Solr convention)
convention), `link` = `/news/article/<path_segment>` per the NewsDetail from the entry's *Data source folders* (empty = 11 + 442) and, when set,
route enhancer, image from `fal_media` as processed 640px webp. in one of its *Data source categories*; `link` =
`/news/article/<path_segment>` per the NewsDetail route enhancer, image
from `fal_media` as processed 640px webp. Pages carrying the menu are
tagged with the news and event tables, so saving such a record refreshes
the menu.
- `events`: the next two events (`hideonwebsite` = 0, upcoming by - `events`: the next two events (`hideonwebsite` = 0, upcoming by
eventstart/eventend), `link` = the record's `eventurl` or the events eventstart/eventend), `link` = the record's `eventurl` or the events
page, image processed the same way. page, image processed the same way.
@@ -27,6 +27,10 @@
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages"><source>Data source folders</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages.description"><source>For data-driven panels: the folders the site package reads its records from (e.g. news folders). Empty = the site package's default.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories"><source>Data source categories</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories.description"><source>For data-driven panels: only records in one of these categories. Empty = no category restriction.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit> <trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
@@ -9,6 +9,8 @@ CREATE TABLE tx_evomegamenujson_entry (
link varchar(1024) DEFAULT '' NOT NULL, link varchar(1024) DEFAULT '' NOT NULL,
layout varchar(30) DEFAULT 'columns' NOT NULL, layout varchar(30) DEFAULT 'columns' NOT NULL,
tree_display varchar(30) DEFAULT 'columns' NOT NULL, tree_display varchar(30) DEFAULT 'columns' NOT NULL,
source_pages text,
source_categories int(11) unsigned DEFAULT '0' NOT NULL,
menu_columns int(11) DEFAULT '0' NOT NULL, menu_columns int(11) DEFAULT '0' NOT NULL,
menu_teasers int(11) DEFAULT '0' NOT NULL, menu_teasers int(11) DEFAULT '0' NOT NULL,
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL, featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
@@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* below /products: * below /products:
* *
* /products * /products
* [folder] category (A, menu label) e.g. "Platforms and End-Points" * category page (A, /products/<slug>) e.g. "Platforms and End-Points"
* [folder] sub-category (B, menu label) e.g. "End-Points" * sub-category page (B, /products/<slug>) e.g. "End-Points"
* overview page (C, /products/<slug>) e.g. "X-Player End-Points" * overview page (C, /products/<slug>) e.g. "X-Player End-Points"
* [shortcut] menu shortcut e.g. "Video Wall Controllers" * [shortcut] menu shortcut e.g. "Video Wall Controllers"
* *
* The page tree is the single source of the products menu (ProvideProduct * The page tree is the single source of the products menu (ProvideProduct
* Catalog reads it): editors rename, reorder or move overview pages there. * Catalog reads it): editors rename, reorder or move overview pages there.
* A and B are sysfolders - never routed, never in navigation menus. The 20 * All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they * new or still empty, get generated content: hero (page title, placeholder
* are new or still empty, generated content: hero (page title, placeholder
* background - 0026-FE), one intro paragraph per grouping title (or * background - 0026-FE), one intro paragraph per grouping title (or
* "Product range"), a product card per sub-product (record teaser, link * "Product range"), a product card per sub-product (record teaser, link
* /product/<slug>), contact pre-footer. Existing pages whose hero carries * /product/<slug>), contact pre-footer. Existing pages whose hero carries
* no image get the placeholder backfilled; any existing reference wins. * no image get the placeholder backfilled; any existing reference wins.
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images. * Afterwards run vitec:wrap-cards-grid per page.
*
* A and B were sysfolders (menu labels) until 2026-11-05; Olli: every
* category and sub-category must be a clickable page. Decisions 2026-11-05:
* - ADOPT: six sub-categories ARE a 2026-10 category page (Joe's category
* workbooks; their "Why VITEC" cards match the sub-category icon sets).
* The page moves into the folder's place, keeps content, slug and SEO,
* gets the sitemap name as nav_title; the folder's children move below
* it and the emptied folder is deleted.
* - MERGE: "Capture & Graphics" gets the main column of BOTH matching
* pages (Graphics & Multi-Display, Video Capture) copied below each other.
* - the rest (3 categories, End-Points, RF Gateway) are converted folders
* with generated content: hero, "Product range", one card per child page
* (title, child list, the child's page media, link), contact pre-footer.
* - the 2026-10 pages without a place (Graphics & Multi-Display, Video
* Capture, Infrastructure & Accessories) leave the menus (nav_hide) but
* stay reachable.
* Folders become pages and pages move by direct UPDATE - not through the
* DataHandler, whose slug hook (EXT:redirects) would rewrite the flat slugs.
* *
* Decisions without customer confirmation (Olli: proceed, nothing deleted): * Decisions without customer confirmation (Olli: proceed, nothing deleted):
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the * - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
@@ -152,6 +169,43 @@ final class BuildProductOverviewsCommand extends Command
]], ]],
]; ];
/** category (A) and sub-category (B) pages built here: flat slugs like the overview pages */
private const SECTION_SLUGS = [
'Platforms and End-Points' => 'platforms-and-end-points',
'End-Points' => 'end-points',
'IP Video Streaming, Distribution & KVM' => 'ip-video-streaming-distribution-and-kvm',
'RF Gateway' => 'rf-gateway',
'Video Wall Controllers & Management' => 'video-wall-controllers-and-management',
'Capture & Graphics' => 'capture-and-graphics',
];
/**
* Sub-categories that ARE a 2026-10 category page (Joe's category
* workbooks, imported 2026-10-27 - their "Why VITEC" cards match the
* agency's sub-category icon sets 1:1). Decision Olli 2026-11-05: the
* page moves into the tree in place of the folder and keeps content,
* slug and SEO; the sitemap name becomes its nav_title (menu label).
*/
private const ADOPT = [
'IPTV Distribution & Signage Platform' => '/products/iptv-and-digital-signage',
'Control Room Platform' => '/products/control-room-platforms',
'Encoders & Decoders' => '/products/video-encoding-and-streaming',
'IP Gateway & Transcoder' => '/products/gateways-and-transcoding',
'AVoIP & KVM' => '/products/av-over-ip-and-kvm',
'Video Walls' => '/products/video-wall-and-display-processing',
];
/** sub-category built from the content of two category pages (main column of both, in order) */
private const MERGE = [
'Capture & Graphics' => ['/products/graphics-and-multi-display', '/products/video-capture'],
];
/** 2026-10 category pages without a place in the tree: stay reachable, leave the menus */
private const LEGACY_CATEGORY_SLUGS = [
'/products/graphics-and-multi-display', '/products/video-capture',
'/products/infrastructure-and-accessories',
];
/** /**
* Sitemap names that do not exist as records - mapped onto the existing * Sitemap names that do not exist as records - mapped onto the existing
* record by slug. The Vision "+1" names are almost certainly an Excel * record by slug. The Vision "+1" names are almost certainly an Excel
@@ -179,6 +233,24 @@ final class BuildProductOverviewsCommand extends Command
private array $report = []; private array $report = [];
private int $newCounter = 0; private int $newCounter = 0;
private bool $apply = false; private bool $apply = false;
/** @var array<string,string> section page id => title */
private array $sectionTitles = [];
/** @var array<string,array<int,array<string,mixed>>> section page id => child specs (card order) */
private array $sectionChildren = [];
/** @var string[] section page ids that get generated content (new or empty) */
private array $sectionsToFill = [];
/** @var array<int,array<string,mixed>> folder uid => page fields, written by direct UPDATE */
private array $conversions = [];
/** @var int[] legacy category pages that leave the menus */
private array $legacyToHide = [];
/** @var array<int,array{page:int,folder:int,parent:int,navTitle:string}> adopted 2026-10 pages, applied by direct UPDATE */
private array $adoptions = [];
/** @var array<string,string> sub-category id => id of the node its children currently hang on (the old folder) */
private array $lookupParent = [];
/** @var array<string,int[]> merge target id => source page uids */
private array $merges = [];
/** @var array<string,string> overview slug => product names (card text on the sub-category page) */
private array $overviewNames = [];
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */ /** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
private ?array $products = null; private ?array $products = null;
@@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command
$shortcuts = []; // [id, spec] $shortcuts = []; // [id, spec]
foreach (self::STRUCTURE as [$categoryTitle, $groups]) { foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
$categoryId = $this->folder($rootId, $categoryTitle, 'category'); $categoryId = $this->section($rootId, $categoryTitle, 'category');
$categoryChildren = [];
foreach ($groups as [$groupTitle, $entries]) { foreach ($groups as [$groupTitle, $entries]) {
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category'); $groupId = $this->section($categoryId, $groupTitle, 'sub-category');
$groupFolderIds[$groupTitle] = $groupId; $groupFolderIds[$groupTitle] = $groupId;
$groupChildren = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if (isset($entry['shortcut'])) { if (isset($entry['shortcut'])) {
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry]; $shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
$groupChildren[] = ['shortcut' => $entry];
continue; continue;
} }
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry); $overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
$groupChildren[] = [
'id' => $overviewBySlug[$entry['slug']],
'title' => (string)$entry['page'],
'text' => $this->overviewNames[$entry['slug']] ?? '',
];
} }
$this->sectionChildren[$groupId] = $groupChildren;
$categoryChildren[] = [
'id' => $groupId,
'title' => $groupTitle,
'text' => implode(', ', array_column(array_filter($groupChildren, static fn(array $c): bool => isset($c['id'])), 'title')),
];
} }
$this->sectionChildren[$categoryId] = $categoryChildren;
} }
// shortcut targets (a group folder or an overview page) // shortcut targets (a sub-category page or an overview page); on the
// sub-category page the shortcut becomes a card linking its target
$targetOf = function (array $spec) use ($groupFolderIds, $overviewBySlug): ?string {
return isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
};
foreach ($shortcuts as [$shortcutId, $spec]) { foreach ($shortcuts as [$shortcutId, $spec]) {
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null); $target = $targetOf($spec);
if ($target === null) { if ($target === null) {
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found'; $this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
continue; continue;
@@ -229,6 +320,25 @@ final class BuildProductOverviewsCommand extends Command
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target; $this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0; $this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
} }
foreach ($this->sectionChildren as $sectionId => $children) {
foreach ($children as $i => $child) {
if (isset($child['shortcut'])) {
$target = $targetOf($child['shortcut']);
$this->sectionChildren[$sectionId][$i] = $target === null ? null : [
'id' => $target,
'title' => (string)$child['shortcut']['shortcut'],
'text' => (string)($child['shortcut']['toGroup'] ?? ''),
];
}
}
$this->sectionChildren[$sectionId] = array_values(array_filter($this->sectionChildren[$sectionId]));
}
$this->sectionMeta();
foreach ($this->sectionsToFill as $sectionId) {
$this->sectionContent($sectionId);
}
$this->legacyCategoryPages();
foreach ($this->report as $line) { foreach ($this->report as $line) {
$output->writeln($line); $output->writeln($line);
@@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
// folders -> pages and legacy nav_hide by direct UPDATE (see class
// comment: the DataHandler slug hook would rewrite child slugs)
$pageConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach ($this->conversions as $uid => $fields) {
$pageConnection->update('pages', $fields, ['uid' => $uid]);
}
foreach ($this->adoptions as $adoption) {
$pageConnection->update('pages', ['pid' => $adoption['parent'], 'nav_hide' => 0, 'hidden' => 0, 'nav_title' => $adoption['navTitle']], ['uid' => $adoption['page']]);
$pageConnection->update('pages', ['pid' => $adoption['parent']], ['l10n_parent' => $adoption['page']]);
if ($adoption['folder'] > 0) {
$pageConnection->update('pages', ['pid' => $adoption['page']], ['pid' => $adoption['folder'], 'deleted' => 0]);
$pageConnection->update('pages', ['deleted' => 1], ['uid' => $adoption['folder']]);
}
}
foreach ($this->legacyToHide as $uid) {
$pageConnection->update('pages', ['nav_hide' => 1], ['uid' => $uid]);
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []); $dataHandler->start($this->datamap, []);
$dataHandler->process_datamap(); $dataHandler->process_datamap();
@@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command
return Command::FAILURE; return Command::FAILURE;
} }
$this->applyOrder($dataHandler->substNEWwithIDs); $this->applyOrder($dataHandler->substNEWwithIDs);
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
$output->writeln("\n<info>Written.</info> Next, per overview page:"); $output->writeln("\n<info>Written.</info> Next, per page with generated cards:");
foreach ($overviewBySlug as $slug => $id) { foreach ($this->sectionsToFill as $id) {
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply'); $slug = $this->conversions[(int)$id]['slug'] ?? ('/products/' . self::SECTION_SLUGS[$this->sectionTitles[$id]]);
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=' . $slug . ' --apply');
} }
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush'); $output->writeln('(overview pages as needed: --page=/products/<slug>), then vitec:build-why-vitec-bands and cache:flush');
return Command::SUCCESS; return Command::SUCCESS;
} }
// ---- structure ------------------------------------------------------------------- // ---- structure -------------------------------------------------------------------
private function folder(string $parentId, string $title, string $kind): string /**
* A category (A) or sub-category (B) page, found by title below its
* parent. A sysfolder from the 2026-11-02 build is queued for conversion
* into a page; an empty page is queued for generated content.
*/
private function section(string $parentId, string $title, string $kind): string
{ {
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null; $existing = ctype_digit($parentId)
if ($existing !== null) { ? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null;
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing); if (isset(self::ADOPT[$title])) {
$this->pageOrder[$parentId][] = (string)$existing; return $this->adopt($parentId, $title, $existing);
return (string)$existing;
} }
$id = $this->newId(); $slug = '/products/' . self::SECTION_SLUGS[$title];
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0]; if ($existing === null) {
$id = $this->newId();
$this->datamap['pages'][$id] = [
'pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
];
$this->pageOrder[$parentId][] = $id;
$this->sectionTitles[$id] = $title;
$this->sectionsToFill[] = $id;
$this->report[] = sprintf('%s page "%s" %s CREATE', $kind, $title, $slug);
return $id;
}
$uid = $existing['uid'];
$id = (string)$uid;
$this->pageOrder[$parentId][] = $id; $this->pageOrder[$parentId][] = $id;
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title); $this->sectionTitles[$id] = $title;
if ($existing['doktype'] === self::DOKTYPE_FOLDER) {
$taken = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND uid <> ? AND sys_language_uid IN (0,-1)',
[$slug, $uid]
);
if ($taken !== false) {
$this->report[] = sprintf('%s folder "%s" #%d: <error>slug %s taken by #%d - NOT converted</error>', $kind, $title, $uid, $slug, (int)$taken);
return $id;
}
$this->conversions[$uid] = [
'doktype' => self::DOKTYPE_PAGE, 'slug' => $slug, 'layout' => self::LAYOUT,
'nav_hide' => 0, 'hidden' => 0,
];
$this->report[] = sprintf('%s folder "%s" #%d -> page %s', $kind, $title, $uid, $slug);
// a redirect on the slug wins over the page (2026-11-05: the 2026-10
// stub redirect /products/platforms-and-end-points hid the new page)
$redirect = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect')->fetchAssociative(
'SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND disabled = 0 AND source_path IN (?, ?)',
[$slug, $slug . '/']
);
if ($redirect) {
$this->report[] = sprintf(' <error>! redirect #%d %s -> %s overrides this page - disable it (Redirects module)</error>', (int)$redirect['uid'], $slug, $redirect['target']);
}
} else {
$this->report[] = sprintf('%s page "%s" #%d (exists)', $kind, $title, $uid);
}
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$uid]);
if ($contentCount === 0) {
$this->sectionsToFill[] = $id;
} else {
$this->report[] = sprintf(' (%d elements - content left untouched)', $contentCount);
}
if (isset(self::MERGE[$title])) {
$this->merges[$id] = [];
foreach (self::MERGE[$title] as $sourceSlug) {
$source = $this->pageBySlug($sourceSlug);
if ($source === null) {
$this->report[] = ' ! merge source ' . $sourceSlug . ' not found';
continue;
}
$this->merges[$id][] = $source;
}
}
return $id; return $id;
} }
/**
* A 2026-10 category page takes the sub-category's place (ADOPT): it
* moves below the category at the folder's position, the folder's
* children move below it, the emptied folder is deleted. Content, slug
* and SEO of the page stay; nav_title = sitemap name.
*
* @param array{uid:int,doktype:int}|null $folder
*/
private function adopt(string $parentId, string $title, ?array $folder): string
{
$page = $this->pageBySlug(self::ADOPT[$title]);
if ($page === null) {
$this->report[] = sprintf('sub-category "%s": <error>page %s not found - NOT adopted</error>', $title, self::ADOPT[$title]);
return $folder !== null ? (string)$folder['uid'] : $this->newId();
}
$id = (string)$page;
$this->pageOrder[$parentId][] = $id;
$this->lookupParent[$id] = $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? (string)$folder['uid'] : $id;
if (!ctype_digit($parentId)) {
$this->report[] = sprintf('sub-category "%s": <error>category not built yet - run again</error>', $title);
return $id;
}
$this->adoptions[] = [
'page' => $page,
'folder' => $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? $folder['uid'] : 0,
'parent' => (int)$parentId,
'navTitle' => $title,
];
$this->report[] = sprintf(
'sub-category "%s" = %s #%d (content, slug, SEO stay; menu title "%s")%s',
$title, self::ADOPT[$title], $page, $title,
$folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? '; folder #' . $folder['uid'] . ' -> children move, folder deleted' : ''
);
return $id;
}
private function pageBySlug(string $slug): ?int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$slug]
);
return $uid !== false ? (int)$uid : null;
}
/** SEO/OG title, description and abstract of the section pages, from their children. */
private function sectionMeta(): void
{
foreach ($this->sectionTitles as $id => $title) {
$names = array_column($this->sectionChildren[$id] ?? [], 'title');
$description = 'VITEC ' . $title . ($names !== [] ? ': ' . implode(', ', $names) : '') . '.';
$meta = [
'seo_title' => $title . ' | VITEC', 'description' => $description,
'og_title' => $title . ' | VITEC', 'og_description' => $description,
'abstract' => implode(', ', $names),
];
if (isset($this->conversions[(int)$id])) {
$this->conversions[(int)$id] += $meta;
} elseif (isset($this->datamap['pages'][$id])) {
$this->datamap['pages'][$id] += $meta;
}
}
}
/**
* Generated content of a new or empty category / sub-category page:
* hero, "Product range", one card per child page (title, child list as
* text, the child's first page media as image, link), contact pre-footer.
*/
private function sectionContent(string $pageId): void
{
$title = $this->sectionTitles[$pageId];
$this->report[] = sprintf('content for "%s" (%s):', $title, ctype_digit($pageId) ? '#' . $pageId : 'new page');
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', ['header' => $title, 'header_layout' => 1, 'bodytext' => '']);
$placeholder = $this->heroPlaceholder($pageId);
if ($placeholder !== null) {
$this->report[] = ' hero: ' . ($this->apply ? 'attach ' : 'would attach ') . $placeholder;
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $this->pid($pageId),
'table_local' => 'sys_file',
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
->getDefaultStorage()->getFile($placeholder)->getUid(),
];
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
}
}
if (isset($this->merges[$pageId])) {
// MERGE target: the main column is copied from the source pages after the run
$this->report[] = ' main column: copied from ' . count($this->merges[$pageId]) . ' page(s) #' . implode(', #', $this->merges[$pageId]) . ' (after the write)';
} else {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
}
foreach (isset($this->merges[$pageId]) ? [] : ($this->sectionChildren[$pageId] ?? []) as $child) {
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => (string)$child['title'],
'bodytext' => $this->p((string)$child['text']),
'vitec_primary_cta_label' => 'Learn more',
'vitec_primary_cta' => ctype_digit((string)$child['id']) ? 't3://page?uid=' . $child['id'] : '',
];
$fileUid = ctype_digit((string)$child['id']) ? $this->pageMediaFile((int)$child['id']) : 0;
$cardId = $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
if ($fileUid > 0) {
$this->datamap['tt_content'][$cardId]['vitec_layout_variant'] = 'vertical';
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = ['pid' => $this->pid($pageId), 'table_local' => 'sys_file', 'uid_local' => $fileUid];
$this->datamap['tt_content'][$cardId]['vitec_image'] = $refId;
}
}
$this->report[] = sprintf(' card "%s"%s%s', $child['title'], $fields['vitec_primary_cta'] !== '' ? ' -> ' . $fields['vitec_primary_cta'] : ' (link after the first run)', $fileUid > 0 ? ' + page image' : '');
}
$this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [
'header' => 'Talk to a VITEC expert',
'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'),
'vitec_primary_cta_label' => 'Contact us',
'vitec_primary_cta' => $this->contactLink(),
'vitec_layout_variant' => 'centered',
'vitec_background_variant' => 'orange',
]);
}
/** The 2026-10 category pages below /products leave the menus (nav_hide), stay reachable. */
private function legacyCategoryPages(): void
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach (self::LEGACY_CATEGORY_SLUGS as $slug) {
$row = $pages->fetchAssociative('SELECT uid, nav_hide FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
if (!$row) {
$this->report[] = 'legacy ' . $slug . ': not found';
} elseif ((int)$row['nav_hide'] === 0) {
$this->legacyToHide[] = (int)$row['uid'];
$this->report[] = sprintf('legacy %s #%d: hide in menus (stays reachable)', $slug, (int)$row['uid']);
}
}
}
/**
* MERGE: copies the visible top-level main-column elements of the source
* pages, in order, below each other onto the (new, previously empty)
* target page. One DataHandler copy per element, each placed after the
* previous copy; EXT:container copies a grid's children along. Re-runs
* do nothing - the target is only filled while it is empty.
*
* @param array<string,int> $subst
*/
private function copyMergedContent(array $subst, OutputInterface $output): void
{
$content = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
foreach ($this->merges as $targetId => $sources) {
$targetId = (string)$targetId;
if (!in_array($targetId, $this->sectionsToFill, true)) {
continue;
}
$targetUid = ctype_digit($targetId) ? (int)$targetId : (int)($subst[$targetId] ?? 0);
if ($targetUid === 0) {
continue;
}
$after = 0;
$copied = 0;
foreach ($sources as $sourceUid) {
$elements = $content->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0) ORDER BY sorting, uid',
[$sourceUid, self::COLPOS_MAIN]
);
foreach ($elements as $elementUid) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], ['tt_content' => [(int)$elementUid => ['copy' => $after > 0 ? -$after : $targetUid]]]);
$dataHandler->process_cmdmap();
$newUid = (int)($dataHandler->copyMappingArray_merged['tt_content'][(int)$elementUid] ?? 0);
if ($newUid > 0) {
$after = $newUid;
$copied++;
}
}
}
$output->writeln(sprintf('merged %d element(s) onto #%d', $copied, $targetUid));
}
}
/** sys_file uid of a page's first media reference, 0 when none */
private function pageMediaFile(int $pageUid): int
{
return (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'pages' AND fieldname = 'media' AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1",
[$pageUid]
);
}
private function shortcutPage(string $groupId, string $title): string private function shortcutPage(string $groupId, string $title): string
{ {
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null; $lookup = $this->lookupParent[$groupId] ?? $groupId; // an adopted page's shortcuts still hang on the old folder
$existing = ctype_digit($lookup) ? ($this->childByTitle((int)$lookup, $title, [self::DOKTYPE_SHORTCUT])['uid'] ?? null) : null;
if ($existing !== null) { if ($existing !== null) {
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing); $this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
$this->pageOrder[$groupId][] = (string)$existing; $this->pageOrder[$groupId][] = (string)$existing;
@@ -296,13 +680,14 @@ final class BuildProductOverviewsCommand extends Command
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug] 'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
); );
[$cards, $names] = $this->resolveItems($entry['items']); [$cards, $names] = $this->resolveItems($entry['items']);
$this->overviewNames[$entry['slug']] = implode(', ', $names);
if ($existing) { if ($existing) {
$pageId = (string)(int)$existing['uid']; $pageId = (string)(int)$existing['uid'];
$this->pageOrder[$groupId][] = $pageId; $this->pageOrder[$groupId][] = $pageId;
$updates = []; $updates = [];
if ((string)(int)$existing['pid'] !== $groupId) { if (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) {
$updates['pid'] = $this->pid($groupId); // moved into its folder $updates['pid'] = $this->pid($groupId); // moved into its sub-category
} }
if ($updates !== []) { if ($updates !== []) {
$this->datamap['pages'][$pageId] = $updates; $this->datamap['pages'][$pageId] = $updates;
@@ -397,7 +782,9 @@ final class BuildProductOverviewsCommand extends Command
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class) 'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
->getDefaultStorage()->getFile($placeholder)->getUid(), ->getDefaultStorage()->getFile($placeholder)->getUid(),
]; ];
$this->datamap['tt_content'][$heroId]['hero_bgimage'] = $refId; // content block vitec/herosection has prefixFields - the real
// column/fieldname is vitec_hero_bgimage (JSON strips the prefix)
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
$this->report[] = ' hero: attach ' . $placeholder; $this->report[] = ' hero: attach ' . $placeholder;
} else { } else {
$this->report[] = ' hero: would attach ' . $placeholder; $this->report[] = ' hero: would attach ' . $placeholder;
@@ -462,7 +849,7 @@ final class BuildProductOverviewsCommand extends Command
return; return;
} }
$existing = (int)$pool->getConnectionForTable('sys_file_reference')->fetchOne( $existing = (int)$pool->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'hero_bgimage' AND uid_foreign = ?", "SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage' AND uid_foreign = ?",
[$heroUid] [$heroUid]
); );
if ($existing > 0) { if ($existing > 0) {
@@ -485,9 +872,9 @@ final class BuildProductOverviewsCommand extends Command
->getDefaultStorage()->getFile($placeholder)->getUid(), ->getDefaultStorage()->getFile($placeholder)->getUid(),
'tablenames' => 'tt_content', 'tablenames' => 'tt_content',
'uid_foreign' => $heroUid, 'uid_foreign' => $heroUid,
'fieldname' => 'hero_bgimage', 'fieldname' => 'vitec_hero_bgimage',
]; ];
$this->datamap['tt_content'][$heroUid]['hero_bgimage'] = 1; $this->datamap['tt_content'][$heroUid]['vitec_hero_bgimage'] = 1;
$this->report[] = ' hero #' . $heroUid . ': attach ' . $placeholder; $this->report[] = ' hero #' . $heroUid . ': attach ' . $placeholder;
} }
@@ -507,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command
// ---- lookups ----------------------------------------------------------------------- // ---- lookups -----------------------------------------------------------------------
private function childByTitle(int $parentUid, string $title, int $doktype): ?int /**
* @param int[] $doktypes
* @return array{uid:int,doktype:int}|null
*/
private function childByTitle(int $parentUid, string $title, array $doktypes): ?array
{ {
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne( $row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid', 'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ')
[$parentUid, $title, $doktype] AND sys_language_uid IN (0,-1) ORDER BY uid',
[$parentUid, $title]
); );
return $uid !== false ? (int)$uid : null; return $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null;
} }
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */ /** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
@@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Builds the "Why VITEC?" icon band on the product OVERVIEW pages
* (/products/<slug>, contract 0036-FE variant 3b): one vitec_introparagraph
* headline plus one vitec_card (icon_card variant - the design's "centered"
* card; 0036 named a variant that does not exist) per staged agency icon of
* that page's context under fileadmin/icons/delivered/products/<context>/.
* Card titles are derived from the icon filename theme ("ScalableArchitecture"
* -> "Scalable architecture"); bodytext stays empty until the agency
* delivers copy (0036: a card without text is fine).
*
* The band lands at the TOP of the main column, before the "Product range"
* intro and its cards (0042-FE: placed after the intro it split heading and
* cards; fix_why_band_position.php corrected the first seven pages).
* Idempotent: a page already carrying a "Why VITEC?" introparagraph gets
* no second band; unused icons of its context go onto existing cards whose
* title matches the icon theme (2026-11-05: the adopted category pages'
* workbook cards) - nothing else is touched. The Vision sub-group icon
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
* decision, reported only.
*
* vendor/bin/typo3 vitec:build-why-vitec-bands
* vendor/bin/typo3 vitec:build-why-vitec-bands --apply
*/
#[AsCommand(
name: 'vitec:build-why-vitec-bands',
description: 'Create the Why-VITEC icon bands on the product overview pages from the staged icons (dry-run by default)'
)]
final class BuildWhyVitecBandsCommand extends Command
{
private const ICON_BASE = 'icons/delivered/products/';
private const HEADER = 'Why VITEC?';
private const COLPOS = 0;
/** staged icon context => overview (or, since 2026-11-05, sub-category) page slug */
private const BANDS = [
'activesqx' => '/products/activesqx',
'aetria' => '/products/aetria',
'aligo' => '/products/aligo',
'avedia-modular-system' => '/products/avedia-modular-system',
'image-graphics-cards' => '/products/image-graphics-cards',
'operator-workstations' => '/products/operator-workstations',
'prism-transcoder' => '/products/prism-transcoder',
// sub-category pages = the adopted 2026-10 category pages (2026-11-05);
// they already carry a "Why VITEC?" section from Joe's workbooks whose
// cards ARE these icon themes -> icons go onto the existing cards
'avoip-and-kvm' => '/products/av-over-ip-and-kvm',
'control-room-platform' => '/products/control-room-platforms',
'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding',
'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage',
];
/** words ignored when an icon theme is matched against a card title */
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
/** acronyms kept upper-case when the filename theme becomes a card title */
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count(self::BANDS) . ' band(s)');
$built = 0;
foreach (self::BANDS as $context => $slug) {
$page = $pool->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)',
[$slug]
);
if (!$page) {
$output->writeln('<error>' . $slug . ': page not found - skipped</error>');
continue;
}
$pageUid = (int)$page['uid'];
$folder = self::ICON_BASE . $context;
$icons = [];
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) === 'svg' && preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$icons[] = ['identifier' => $file->getIdentifier(), 'title' => $this->themeTitle($m[1])];
}
}
}
if ($icons === []) {
$output->writeln('<comment>' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped</comment>');
continue;
}
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
[$pageUid, self::HEADER]
);
if ($existing > 0) {
// the page has its "Why VITEC?" section already (own band or
// a workbook import): put the icons onto matching cards
// without one; everything else stays untouched
$built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output);
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
foreach ($icons as $icon) {
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
}
$built++;
if (!$apply) {
continue;
}
// current main-column order (top-level elements only: the band
// must not land inside a b13 container)
$elements = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid',
[$pageUid, self::COLPOS]
);
$existingUids = array_map(static fn(array $r): string => (string)(int)$r['uid'], $elements);
$datamap = [];
$newIds = [];
$introId = $this->newId();
$datamap['tt_content'][$introId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_introparagraph',
'header' => self::HEADER, 'bodytext' => '',
];
$newIds[] = $introId;
foreach ($icons as $icon) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$cardId = $this->newId();
$datamap['tt_content'][$cardId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_card',
'header' => $icon['title'], 'bodytext' => '',
'vitec_layout_variant' => 'icon_card',
'vitec_icon' => $refId,
];
$newIds[] = $cardId;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// final order: the band first, then the existing main column
$order = array_merge(
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
$existingUids
);
$sorting = 0;
foreach ($order as $uid) {
if ((int)$uid > 0) {
$sorting += 256;
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => (int)$uid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d band(s) %s.</info> Vision sub-group icon sets (vision, visionav) stay unassigned - one page, two sets (editorial).",
$built, $apply ? 'written - flush the cache' : 'planned (dry run - nothing written)'
));
return Command::SUCCESS;
}
/**
* Icons onto the existing cards of a page whose title matches an icon
* theme ("Centralized management" ~ CentralManagement: every word of
* the theme is a prefix of a card-title word or vice versa, same word
* count). Only cards without an icon; they become icon_card like the
* generated bands. Returns 1 when anything was (or would be) attached.
*
* @param array<int,array{identifier:string,title:string}> $icons
*/
private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
"SELECT c.uid, c.header FROM tt_content c
WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card'
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid)
ORDER BY c.uid",
[$pageUid]
);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
// icons already on a card of this page (e.g. the generated bands)
$used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?",
[$pageUid]
));
$datamap = [];
$lines = [];
foreach ($icons as $icon) {
if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) {
continue;
}
foreach ($cards as $k => $card) {
if (!$this->sameTheme($icon['title'], (string)$card['header'])) {
continue;
}
$lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier']));
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card'];
unset($cards[$k]);
continue 2;
}
$lines[] = ' <comment>no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')</comment>';
}
if ($lines === []) {
$output->writeln($slug . ': band complete - untouched');
return 0;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid));
foreach ($lines as $line) {
$output->writeln($line);
}
if ($datamap === []) {
return 0;
}
if ($apply) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
}
}
return 1;
}
private function sameTheme(string $iconTitle, string $cardTitle): bool
{
$words = fn(string $text): array => array_values(array_diff(
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
self::STOPWORDS
));
$a = $words($iconTitle);
$b = $words($cardTitle);
if ($a === [] || count($a) !== count($b)) {
return false;
}
foreach ($a as $i => $word) {
if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) {
return false;
}
}
return true;
}
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
private function themeTitle(string $theme): string
{
$words = preg_split('/(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)/', $theme, -1, PREG_SPLIT_NO_EMPTY) ?: [$theme];
$out = [];
foreach ($words as $i => $word) {
if (in_array(strtoupper($word), self::ACRONYMS, true)) {
$out[] = strtoupper($word);
} elseif (preg_match('/\d/', $word)) {
$out[] = $word;
} else {
$out[] = $i === 0 ? ucfirst(mb_strtolower($word)) : mb_strtolower($word);
}
}
return implode(' ', $out);
}
private function newId(): string
{
return 'NEWWV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
#[AsCommand(
name: 'vitec:client-error-cleanup',
description: 'Delete client error reports (tx_vitec_clienterrorlog) older than 30 days; run daily via cron',
)]
final class ClientErrorCleanupCommand extends Command
{
private const TABLE = 'tx_vitec_clienterrorlog';
private const DEFAULT_DAYS = 30;
public function __construct(private readonly ConnectionPool $connectionPool)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = (int)$input->getOption('days');
if ($days < 1) {
$output->writeln('<error>--days must be at least 1</error>');
return Command::INVALID;
}
$cutoff = time() - $days * 86400;
$conn = $this->connectionPool->getConnectionForTable(self::TABLE);
$deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]);
$remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne();
$output->writeln(sprintf(
'deleted %d client error report(s) older than %d days (before %s), %d remaining',
$deleted,
$days,
date('Y-m-d H:i', $cutoff),
$remaining
));
return Command::SUCCESS;
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Creates the two internal audit pages for the page content check (decision
* Harry 2026-11-13, sibling of /products/product-check):
*
* /markets/market-check vitec_pagecheck, scope markets
* /solutions/solution-check vitec_pagecheck, scope solutions
*
* Page properties are taken from the product check page (/products/
* product-check: doktype, backend layout, no_index, nav_hide, nav_title
* handling) so all three audit pages behave alike. Idempotent: an existing
* slug is reported and skipped.
*
* vendor/bin/typo3 vitec:pagecheck-pages # dry run (default)
* vendor/bin/typo3 vitec:pagecheck-pages --apply # creates
*/
#[AsCommand(
name: 'vitec:pagecheck-pages',
description: 'Create /markets/market-check and /solutions/solution-check with the vitec_pagecheck element (dry-run by default)'
)]
final class CreateCheckPagesCommand extends Command
{
private const TEMPLATE_SLUG = '/products/product-check';
private const PAGES = [
['parentSlug' => '/markets', 'title' => 'Market Check', 'slug' => '/markets/market-check', 'header' => 'Market Content Check', 'scope' => 'markets'],
['parentSlug' => '/solutions', 'title' => 'Solution Check', 'slug' => '/solutions/solution-check', 'header' => 'Solution Content Check', 'scope' => 'solutions'],
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Create the pages (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$template = $db->fetchAssociative(
'SELECT uid, doktype, backend_layout, backend_layout_next_level, no_index, no_follow, nav_hide, no_search, seo_title, description FROM pages WHERE deleted = 0 AND slug = ?',
[self::TEMPLATE_SLUG]
);
if ($template === false) {
$output->writeln('<error>Template page ' . self::TEMPLATE_SLUG . ' not found.</error>');
return Command::FAILURE;
}
$output->writeln(sprintf(
'Template %s (uid %d): doktype %d, layout "%s", no_index %d, nav_hide %d, no_search %d',
self::TEMPLATE_SLUG, $template['uid'], $template['doktype'], $template['backend_layout'], $template['no_index'], $template['nav_hide'], $template['no_search']
));
$output->writeln($apply ? '<comment>APPLY</comment>' : 'DRY RUN - nothing is written (--apply to create)');
$created = 0;
foreach (self::PAGES as $def) {
$existing = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$def['slug']]);
if ($existing !== false) {
$output->writeln(sprintf(' %s exists (uid %d) - skipped', $def['slug'], $existing['uid']));
continue;
}
$parent = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$def['parentSlug']]);
if ($parent === false) {
$output->writeln(sprintf(' <error>parent %s not found - %s skipped</error>', $def['parentSlug'], $def['slug']));
continue;
}
$output->writeln(sprintf(' create %s "%s" under %s (pid %d) + vitec_pagecheck "%s" (scope %s)', $def['slug'], $def['title'], $def['parentSlug'], $parent['uid'], $def['header'], $def['scope']));
$created++;
if (!$apply) {
continue;
}
$pageId = 'NEWpage' . $def['scope'];
$datamap = [
'pages' => [
$pageId => [
'pid' => -(int)$this->lastChild((int)$parent['uid'], (int)$parent['uid']),
'title' => $def['title'],
'slug' => $def['slug'],
'doktype' => (int)$template['doktype'],
'backend_layout' => (string)$template['backend_layout'],
'backend_layout_next_level' => (string)$template['backend_layout_next_level'],
'no_index' => (int)$template['no_index'],
'no_follow' => (int)$template['no_follow'],
'nav_hide' => (int)$template['nav_hide'],
'no_search' => (int)$template['no_search'],
'hidden' => 0,
],
],
'tt_content' => [
'NEWce' . $def['scope'] => [
'pid' => $pageId,
'colPos' => 0,
'sys_language_uid' => 0,
'CType' => 'vitec_pagecheck',
'header' => $def['header'],
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => ['settings.scope' => ['vDEF' => $def['scope']]]]]],
],
],
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
$output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
}
}
$output->writeln(sprintf('%s: %d page(s).', $apply ? 'DONE' : 'DRY RUN - would create', $created));
if ($apply && $created > 0) {
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush');
}
return Command::SUCCESS;
}
/** uid of the last live child page (to append after it), or the parent itself when there is none */
private function lastChild(int $parentUid, int $fallback): int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND pid = ? ORDER BY sorting DESC LIMIT 1',
[$parentUid]
);
return $uid === false ? $fallback : (int)$uid;
}
}
@@ -0,0 +1,688 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Fills the product OVERVIEW pages (/products/<slug>, layout 9, built by
* vitec:build-product-overviews on 2026-11-02) with the agency's editorial
* copy from the "<Overview> - Enriched.xlsx" workbooks (Dropbox delivery
* 2026-11-09, staged in migrations/product_overviews_xlsx_in/).
*
* Workbook shape: sheet "Content" with rows "Section | Content field |
* Website copy / value | Production notes", sections 01 HERO, 02 INTRO,
* 03 WHY VITEC, 04 COMPARISON, 05 PRODUCT RANGE, 06 SOLUTIONS, 07 CTA,
* 08 SEO. The page is found by its title (= the workbook's H1).
*
* The pages already carry generated content (hero, optional "Why VITEC?"
* icon band, "Product range" intro, product card grids, contact banner).
* This importer UPDATES in place and only ADDS what is missing:
*
* 01 HERO -> hero bodytext (subline) + CTA label; title stays
* 02 INTRO -> NEW introparagraph (H2 + introduction) at the top of
* the main column, plus the use-case paragraph
* 03 WHY VITEC -> page with icon band: benefit copy goes onto the card
* whose title matches the benefit heading (theme match
* like vitec:build-why-vitec-bands), unmatched benefits
* are REPORTED only (the band is the agency's icon set);
* page without band: NEW band of icon_card cards without
* icon (0106-FE: the frontend builds the band from
* icon_card only - text_only cards fall apart; a band
* card still carrying text_only is repaired on re-run)
* 04 COMPARISON -> skipped unless --comparison: the workbook holds no
* specifications ("populate from approved datasheets"),
* only the card descriptions again
* 05 PRODUCT RANGE -> the "Product range" intro gets the H2 + intro copy;
* product CARDS stay untouched (they show the record
* teasers from the product workbooks - the overview
* workbook texts are mostly boilerplate)
* 06 SOLUTIONS -> NEW intro + one text_only card per solution, linked
* to the solution group page under /solutions
* 07 CTA -> contact banner header/body/button (link stays)
* 08 SEO -> seo/og/twitter title + description, keywords, abstract
*
* Main-column order after apply: intro, use case, Why VITEC band,
* [comparison], range intro, product cards (unchanged), solutions.
* Idempotent: existing elements are found by header; nothing is duplicated.
*
* vendor/bin/typo3 vitec:import-product-overview-workbooks
* vendor/bin/typo3 vitec:import-product-overview-workbooks --only=aetria --apply
*/
#[AsCommand(
name: 'vitec:import-product-overview-workbooks',
description: 'Fill the product overview pages from the agency "Enriched" workbooks (dry-run by default)'
)]
final class ImportProductOverviewWorkbooksCommand extends Command
{
private const COLPOS_HERO = 1;
private const COLPOS_MAIN = 0;
private const COLPOS_PREFOOTER = 3;
private const WHY_HEADER = 'Why VITEC?';
private const RANGE_PLACEHOLDER = 'Product range';
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
/** workbook H1 (post-norm) => page slug where the title match fails */
private const TITLE_TO_SLUG = [
'xplayerendpoints' => '/products/x-player-end-points',
];
/** workbook solution names that differ from the group page titles beyond spelling (post-norm) */
private const SOLUTION_ALIASES = [
'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
];
/** @var array<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var string[] */
private array $report = [];
private int $newCounter = 0;
private bool $apply = false;
private bool $withComparison = false;
private int $touched = 0;
protected function configure(): void
{
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_overviews_xlsx_in');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
$this->addOption('comparison', null, InputOption::VALUE_NONE, 'Also create the comparison table (text element)');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$this->apply = (bool)$input->getOption('apply');
$this->withComparison = (bool)$input->getOption('comparison');
$only = mb_strtolower((string)$input->getOption('only'));
$dir = rtrim((string)$input->getOption('dir'), '/');
$files = glob($dir . '/*.xlsx') ?: [];
$files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
&& ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
sort($files);
$output->writeln(($this->apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($files) . ' workbook(s) in ' . $dir
. ($this->withComparison ? ' · with comparison table' : ' · comparison table skipped'));
$ok = 0;
foreach ($files as $file) {
try {
if ($this->importOne($file, $output)) {
$ok++;
}
} catch (\Throwable $e) {
$output->writeln('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
$output->writeln(sprintf(
"\n<info>%d of %d workbook(s) %s, %d element/page write(s).</info>%s",
$ok, count($files), $this->apply ? 'applied' : 'planned', $this->touched,
$this->apply ? ' Flush the cache, then vitec:ssr-purge --all + vitec:ssr-warm.' : ' DRY RUN - nothing written. Add --apply to write.'
));
return Command::SUCCESS;
}
private function importOne(string $file, OutputInterface $output): bool
{
$this->datamap = [];
$this->report = [];
$wb = $this->readWorkbook($file);
$h1 = $wb['hero']['h1'] ?? '';
if ($h1 === '') {
$output->writeln('<comment> ' . basename($file) . ': no "01 · HERO / H1" row - skipped</comment>');
return false;
}
$page = $this->pageByTitle($h1);
if ($page === null) {
$output->writeln('<error> ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped'
. ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . '</error>');
return false;
}
$pageUid = (int)$page['uid'];
$output->writeln(sprintf("\n<info>%s</info> -> %s (#%d)", basename($file), $page['slug'], $pageUid));
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$conn = $pool->getConnectionForTable('tt_content');
$top = $conn->fetchAllAssociative(
'SELECT uid, colPos, CType, header, bodytext, vitec_layout_variant, vitec_primary_cta_label, vitec_cta_label FROM tt_content
WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY colPos, sorting, uid',
[$pageUid]
);
$main = array_values(array_filter($top, static fn(array $r): bool => (int)$r['colPos'] === self::COLPOS_MAIN));
$byUid = [];
foreach ($main as $row) {
$byUid[(string)$row['uid']] = $row;
}
// --- 01 hero ------------------------------------------------------------------
$hero = $this->first($top, self::COLPOS_HERO, 'vitec_herosection');
if ($hero === null) {
$this->report[] = 'hero: NO vitec_herosection in colPos 1 - subline/CTA not placed';
} else {
$this->update((string)$hero['uid'], $hero, [
'bodytext' => $this->p($wb['hero']['subline'] ?? ''),
'vitec_cta_label' => $wb['hero']['cta'] ?? '',
], 'hero #' . $hero['uid']);
}
// --- 02 intro + use case ----------------------------------------------------------
$introH2 = ($wb['intro']['h2'] ?? '') !== '' ? $wb['intro']['h2'] : $h1;
$introId = $this->ensureIntro($main, $byUid, $pageUid, $introH2, $wb['intro']['body'] ?? '', 'intro');
$useId = null;
if (($wb['intro']['usecase_body'] ?? '') !== '') {
$useHeading = ($wb['intro']['usecase_heading'] ?? '') !== '' ? $wb['intro']['usecase_heading'] : 'Applications and workflows';
$useId = $this->ensureIntro($main, $byUid, $pageUid, $useHeading, $wb['intro']['usecase_body'], 'use case');
}
// --- 03 why vitec -----------------------------------------------------------------
$whyIds = [];
$whyIndex = $this->indexOf($main, 'vitec_introparagraph', self::WHY_HEADER);
if ($whyIndex !== null) {
$whyIds[] = (string)$main[$whyIndex]['uid'];
$cards = [];
for ($i = $whyIndex + 1; $i < count($main) && $main[$i]['CType'] === 'vitec_card'; $i++) {
$cards[] = $main[$i];
$whyIds[] = (string)$main[$i]['uid'];
}
$used = [];
foreach ($wb['why']['benefits'] as $benefit) {
$match = null;
foreach ($cards as $card) {
if (isset($used[(string)$card['uid']])) {
continue;
}
if ($this->norm((string)$card['header']) === $this->norm($benefit['heading']) || $this->sameTheme($benefit['heading'], (string)$card['header'])) {
$match = $card;
break;
}
}
if ($match === null) {
$this->report[] = 'why: benefit "' . $benefit['heading'] . '" has no matching icon card - NOT placed (band = agency icon set; editorial decision)';
continue;
}
$used[(string)$match['uid']] = true;
if ((string)$match['vitec_layout_variant'] !== 'icon_card') {
// 0106-FE: band cards must be icon_card (first run wrote text_only)
$this->update((string)$match['uid'], $match, ['vitec_layout_variant' => 'icon_card'], 'why card "' . $match['header'] . '" -> icon_card');
}
if (trim(strip_tags((string)$match['bodytext'])) !== '' && $this->norm(strip_tags((string)$match['bodytext'])) !== $this->norm($benefit['body'])) {
$this->report[] = 'why: card "' . $match['header'] . '" already has own text - left as is';
continue;
}
$this->update((string)$match['uid'], $match, ['bodytext' => $this->p($benefit['body'])], 'why card "' . $match['header'] . '" <- "' . $benefit['heading'] . '"');
}
} elseif ($wb['why']['benefits'] !== []) {
$id = $this->create($pageUid, 'vitec_introparagraph', ['header' => self::WHY_HEADER, 'bodytext' => ''], 'why: NEW band intro');
$whyIds[] = $id;
foreach ($wb['why']['benefits'] as $benefit) {
$whyIds[] = $this->create($pageUid, 'vitec_card', [
'vitec_layout_variant' => 'icon_card',
'header' => $benefit['heading'],
'bodytext' => $this->p($benefit['body']),
'vitec_primary_cta_label' => '',
], 'why: NEW card "' . $benefit['heading'] . '" (icon_card, no icon)');
}
}
// --- 04 comparison (opt-in) ------------------------------------------------------------
$comparisonId = null;
if ($this->withComparison && ($wb['comparison']['rows'] ?? []) !== []) {
$h2 = $this->fixCase($wb['comparison']['h2'] ?? 'Compare ' . $h1, $h1);
$body = $this->p($wb['comparison']['intro'] ?? '')
. '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Description</th></tr></thead><tbody>';
foreach ($wb['comparison']['rows'] as $row) {
$body .= '<tr><td>' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '</td><td>'
. htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
}
$body .= '</tbody></table>';
$idx = $this->indexOf($main, 'text', $h2);
if ($idx !== null) {
$comparisonId = (string)$main[$idx]['uid'];
$this->update($comparisonId, $main[$idx], ['bodytext' => $body], 'comparison #' . $comparisonId);
} else {
$comparisonId = $this->create($pageUid, 'text', ['header' => $h2, 'bodytext' => $body], 'comparison: NEW text element (' . count($wb['comparison']['rows']) . ' rows)');
}
} elseif (($wb['comparison']['rows'] ?? []) !== []) {
$this->report[] = 'comparison: ' . count($wb['comparison']['rows']) . ' row(s) skipped (no specifications in the workbook; --comparison to create)';
}
// --- 05 product range intro ----------------------------------------------------------
$rangeH2 = $this->fixCase(($wb['range']['h2'] ?? '') !== '' ? $wb['range']['h2'] : ('Explore the ' . $h1 . ' range'), $h1);
$rangeIdx = $this->indexOf($main, 'vitec_introparagraph', $rangeH2) ?? $this->indexOf($main, 'vitec_introparagraph', self::RANGE_PLACEHOLDER);
if ($rangeIdx !== null) {
$rangeId = (string)$main[$rangeIdx]['uid'];
$this->update($rangeId, $main[$rangeIdx], ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range intro #' . $rangeId);
} else {
$rangeId = $this->create($pageUid, 'vitec_introparagraph', ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range: NEW intro (grouped page)');
}
$names = array_map(static fn(array $p): string => $p['name'], $wb['range']['products'] ?? []);
$cardsOnPage = (int)$conn->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_card' AND colPos >= 200",
[$pageUid]
);
$this->report[] = 'range: ' . count($names) . ' workbook product(s), ' . $cardsOnPage . ' card(s) in grids on the page - cards untouched (record teasers win)';
// --- 06 solutions ------------------------------------------------------------------------
$solIds = [];
if (($wb['solutions']['items'] ?? []) !== []) {
$h2 = $this->fixCase(($wb['solutions']['h2'] ?? '') !== '' ? $wb['solutions']['h2'] : ('Solutions using ' . $h1), $h1);
$idx = $this->indexOf($main, 'vitec_introparagraph', $h2);
if ($idx !== null) {
$this->report[] = 'solutions: intro "' . $h2 . '" exists - block left as is';
} else {
$solIds[] = $this->create($pageUid, 'vitec_introparagraph', ['header' => $h2, 'bodytext' => ''], 'solutions: NEW intro "' . $h2 . '"');
foreach ($wb['solutions']['items'] as $item) {
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => $item['title'],
'bodytext' => $this->p($item['text']),
'vitec_primary_cta_label' => 'Learn more',
];
$target = $this->resolveSolutionPage($item['title']);
if ($target > 0) {
$fields['vitec_primary_cta'] = 't3://page?uid=' . $target;
}
$solIds[] = $this->create($pageUid, 'vitec_card', $fields, 'solutions: NEW card "' . $item['title'] . '" -> ' . ($target > 0 ? 'page #' . $target : 'NO solution page resolved (link empty)'));
}
}
}
// --- 07 cta banner -------------------------------------------------------------------------
$cta = $this->first($top, self::COLPOS_PREFOOTER, 'vitec_ctabanner');
if ($cta === null) {
$this->report[] = 'cta: NO vitec_ctabanner in colPos 3 - not placed';
} elseif (($wb['cta']['h2'] ?? '') !== '') {
$this->update((string)$cta['uid'], $cta, [
'header' => $this->fixCase($wb['cta']['h2'], $h1),
'bodytext' => $this->p($wb['cta']['body'] ?? ''),
'vitec_primary_cta_label' => ($wb['cta']['button'] ?? '') !== '' ? $wb['cta']['button'] : 'Contact us',
], 'cta #' . $cta['uid']);
}
// --- 08 seo / page properties -------------------------------------------------------------
$seoTitle = ($wb['seo']['title'] ?? '') !== '' ? $wb['seo']['title'] : $h1 . ' | VITEC';
$desc = $wb['seo']['description'] ?? '';
$keywords = implode(', ', array_filter([$wb['seo']['primary'] ?? '', $wb['seo']['secondary'] ?? '']));
$props = [
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
'description' => $desc, 'og_description' => $desc, 'twitter_description' => $desc,
'keywords' => $keywords,
'abstract' => mb_substr(trim(($wb['intro']['body'] ?? '') !== '' ? $wb['intro']['body'] : $desc), 0, 300),
];
$changed = [];
foreach ($props as $field => $value) {
if (trim((string)($page[$field] ?? '')) !== trim($value)) {
$changed[] = $field;
$this->datamap['pages'][(string)$pageUid][$field] = $value;
}
}
if ($changed !== []) {
$this->touched++;
$this->report[] = 'page meta: ' . implode(', ', $changed);
} else {
$this->report[] = 'page meta: unchanged';
}
foreach ($this->report as $line) {
$output->writeln(' ' . $line);
}
if (!$this->apply) {
return true;
}
// --- write -----------------------------------------------------------------------------------
$subst = [];
if ($this->datamap !== []) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
$subst = $dataHandler->substNEWwithIDs;
}
// final main-column order: intro, use case, why band, [comparison], range intro, the rest (cards/grids/group intros), solutions
$order = array_merge([$introId], $useId !== null ? [$useId] : [], $whyIds, $comparisonId !== null ? [$comparisonId] : [], [$rangeId]);
$placed = array_flip($order);
foreach ($main as $row) {
if (!isset($placed[(string)$row['uid']])) {
$order[] = (string)$row['uid'];
}
}
$order = array_merge($order, $solIds);
$sorting = 0;
foreach ($order as $id) {
$uid = str_starts_with($id, 'NEW') ? (int)($subst[$id] ?? 0) : (int)$id;
if ($uid > 0) {
$sorting += 256;
$conn->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
return true;
}
// ---- element helpers ---------------------------------------------------------------------------
/** @param array<int,array<string,mixed>> $rows */
private function first(array $rows, int $colPos, string $ctype): ?array
{
foreach ($rows as $row) {
if ((int)$row['colPos'] === $colPos && $row['CType'] === $ctype) {
return $row;
}
}
return null;
}
/** @param array<int,array<string,mixed>> $main */
private function indexOf(array $main, string $ctype, string $header): ?int
{
$needle = $this->norm($header);
foreach ($main as $i => $row) {
if ($row['CType'] === $ctype && $this->norm((string)$row['header']) === $needle) {
return $i;
}
}
return null;
}
/**
* Existing introparagraph with this header (updated) or a new one.
*
* @param array<int,array<string,mixed>> $main
* @param array<string,array<string,mixed>> $byUid
*/
private function ensureIntro(array $main, array $byUid, int $pageUid, string $header, string $body, string $label): string
{
$idx = $this->indexOf($main, 'vitec_introparagraph', $header);
if ($idx !== null) {
$id = (string)$main[$idx]['uid'];
$this->update($id, $byUid[$id], ['header' => $header, 'bodytext' => $this->p($body)], $label . ' #' . $id);
return $id;
}
return $this->create($pageUid, 'vitec_introparagraph', ['header' => $header, 'bodytext' => $this->p($body)], $label . ': NEW "' . $header . '"');
}
/** @param array<string,mixed> $fields */
private function create(int $pageUid, string $ctype, array $fields, string $label): string
{
$id = $this->newId();
$this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => self::COLPOS_MAIN, 'CType' => $ctype] + $fields;
$this->report[] = $label;
$this->touched++;
return $id;
}
/**
* Writes only fields whose current value differs (RTE-tolerant on bodytext).
*
* @param array<string,mixed> $current
* @param array<string,string> $fields
*/
private function update(string $uid, array $current, array $fields, string $label): void
{
$diff = [];
foreach ($fields as $field => $value) {
$old = (string)($current[$field] ?? '');
$same = $field === 'bodytext'
? $this->norm(strip_tags($old)) === $this->norm(strip_tags($value))
: trim($old) === trim($value);
if (!$same) {
$diff[] = $field;
$this->datamap['tt_content'][$uid][$field] = $value;
}
}
if ($diff === []) {
$this->report[] = $label . ': unchanged';
return;
}
$this->touched++;
$this->report[] = $label . ': ' . implode(', ', $diff);
}
// ---- resolution --------------------------------------------------------------------------------
/** @return array<string,mixed>|null */
private function pageByTitle(string $title): ?array
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$needle = $this->norm($title);
$rows = $pages->fetchAllAssociative(
"SELECT uid, slug, title, nav_title, seo_title, og_title, twitter_title, description, og_description, twitter_description, keywords, abstract
FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND slug LIKE '/products/%'"
);
if (isset(self::TITLE_TO_SLUG[$needle])) {
foreach ($rows as $row) {
if ($row['slug'] === self::TITLE_TO_SLUG[$needle]) {
return $row;
}
}
}
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['title']) === $needle));
if (count($hits) === 1) {
return $hits[0];
}
if (count($hits) > 1) {
// prefer the flat overview slug (one segment below /products)
$flat = array_values(array_filter($hits, static fn(array $r): bool => substr_count((string)$r['slug'], '/') === 2));
if (count($flat) === 1) {
return $flat[0];
}
$this->report[] = 'ambiguous title "' . $title . '": ' . implode(', ', array_map(static fn(array $r): string => $r['slug'], $hits));
return null;
}
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['nav_title']) === $needle));
return count($hits) === 1 ? $hits[0] : null;
}
private function resolveSolutionPage(string $title): int
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$root = $pages->fetchOne("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
if (!$root) {
return 0;
}
$needle = $this->norm($title);
$needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
$strip = static fn(string $n): string => (string)preg_replace('/solutions$/', '', $n);
$rows = $pages->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) AND doktype = 1',
[(int)$root]
);
foreach ($rows as $row) {
if ($this->norm((string)$row['title']) === $needle) {
return (int)$row['uid'];
}
}
foreach ($rows as $row) {
if ($strip($this->norm((string)$row['title'])) === $strip($needle)) {
return (int)$row['uid'];
}
}
return 0;
}
// ---- workbook reading --------------------------------------------------------------------------
/** @return array<string,mixed> */
private function readWorkbook(string $file): array
{
$spreadsheet = IOFactory::load($file);
$sheet = $spreadsheet->getSheetByName('Content') ?? $spreadsheet->getSheet($spreadsheet->getSheetCount() - 1);
$rows = $sheet->toArray(null, false, false, false);
$spreadsheet->disconnectWorksheets();
$wb = ['hero' => [], 'intro' => [], 'why' => ['benefits' => []], 'comparison' => ['rows' => []],
'range' => ['products' => []], 'solutions' => ['items' => []], 'cta' => [], 'seo' => []];
$benefits = [];
$products = [];
$solutions = [];
foreach ($rows as $row) {
$section = trim((string)($row[0] ?? ''));
$field = trim((string)($row[1] ?? ''));
$value = trim((string)($row[2] ?? ''));
if ($section === '' || $field === '' || !preg_match('/^(\d\d)/', $section, $m)) {
continue;
}
$f = mb_strtolower($field);
switch ($m[1]) {
case '01':
if ($f === 'h1') {
$wb['hero']['h1'] = $value;
} elseif (str_contains($f, 'subline')) {
$wb['hero']['subline'] = $value;
} elseif (str_contains($f, 'cta')) {
$wb['hero']['cta'] = $value;
}
break;
case '02':
if ($f === 'h2') {
$wb['intro']['h2'] = $value;
} elseif ($f === 'introduction') {
$wb['intro']['body'] = $value;
} elseif (str_contains($f, 'use-case card heading')) {
$wb['intro']['usecase_heading'] = $value;
} elseif (str_contains($f, 'use-case description')) {
$wb['intro']['usecase_body'] = $value;
}
break;
case '03':
if (preg_match('/^benefit\s*(\d+)\s*[—–-]+\s*(heading|body)$/u', $f, $b)) {
$benefits[(int)$b[1]][$b[2]] = $value;
} elseif (str_contains($f, 'h2')) {
$wb['why']['h2'] = $value;
}
break;
case '04':
if ($f === 'h2') {
$wb['comparison']['h2'] = $value;
} elseif ($f === 'intro') {
$wb['comparison']['intro'] = $value;
} elseif (preg_match('/^product\s*\d+\s*:\s*(.+)$/iu', $field, $c)) {
$wb['comparison']['rows'][] = ['name' => trim($c[1]), 'text' => $value];
}
break;
case '05':
if ($f === 'h2') {
$wb['range']['h2'] = $value;
} elseif ($f === 'intro') {
$wb['range']['intro'] = $value;
} elseif (preg_match('/^product\s*(\d+)\s*[—–-]+\s*(group|name|description|cta|target url)$/u', $f, $p)) {
$products[(int)$p[1]][$p[2]] = $value;
}
break;
case '06':
if ($f === 'h2') {
$wb['solutions']['h2'] = $value;
} elseif (preg_match('/^solution\s*(\d+)\s*[—–-]+\s*(title|description|link)$/u', $f, $s)) {
$solutions[(int)$s[1]][$s[2]] = $value;
}
break;
case '07':
if ($f === 'h2') {
$wb['cta']['h2'] = $value;
} elseif ($f === 'body') {
$wb['cta']['body'] = $value;
} elseif ($f === 'button') {
$wb['cta']['button'] = $value;
} elseif ($f === 'url') {
$wb['cta']['url'] = $value;
}
break;
case '08':
if (str_contains($f, 'seo title')) {
$wb['seo']['title'] = $value;
} elseif (str_contains($f, 'meta description')) {
$wb['seo']['description'] = $value;
} elseif (str_contains($f, 'primary keyword')) {
$wb['seo']['primary'] = $value;
} elseif (str_contains($f, 'secondary keyword')) {
$wb['seo']['secondary'] = $value;
}
break;
}
}
ksort($benefits);
foreach ($benefits as $b) {
if (($b['heading'] ?? '') !== '') {
$wb['why']['benefits'][] = ['heading' => $b['heading'], 'body' => $b['body'] ?? ''];
}
}
ksort($products);
foreach ($products as $p) {
if (($p['name'] ?? '') !== '') {
$wb['range']['products'][] = ['group' => $p['group'] ?? '', 'name' => $p['name'], 'text' => $p['description'] ?? ''];
}
}
ksort($solutions);
foreach ($solutions as $s) {
if (($s['title'] ?? '') !== '') {
$wb['solutions']['items'][] = ['title' => $s['title'], 'text' => $s['description'] ?? '', 'link' => $s['link'] ?? ''];
}
}
return $wb;
}
// ---- text helpers --------------------------------------------------------------------------------
/** "Compare aetria" -> "Compare Aetria": the template lower-cased the product name inside the H2s */
private function fixCase(string $text, string $h1): string
{
$lower = mb_strtolower($h1);
if ($lower === '' || $lower === $h1) {
return $text;
}
return (string)preg_replace('/' . preg_quote($lower, '/') . '/u', $h1, $text);
}
private function sameTheme(string $a, string $b): bool
{
$words = fn(string $text): array => array_values(array_diff(
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
self::STOPWORDS
));
$x = $words($a);
$y = $words($b);
if ($x === [] || count($x) !== count($y)) {
return false;
}
foreach ($x as $i => $word) {
if (!str_starts_with($y[$i], $word) && !str_starts_with($word, $y[$i])) {
return false;
}
}
return true;
}
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
{
$value = mb_strtolower($value);
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
}
private function newId(): string
{
return 'NEWOV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT);
}
}
@@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Ticket 0124-FE (2026-11-12): Joe aligned the market pages with Figma, the
* front end renders the new bands (0118-FE) - this command creates the
* content they need. Per main market page (/markets/<slug>, 11 pages):
*
* B1 vitec_marketintro: numbered placeholder image placeholders/pages/<uid>-02.png
* when the element has no image yet; learnmore_target "/contact" when empty.
* B2 vitec_customerlogos ("Trusted by"): the 2026-10-23 page import left a
* HIDDEN shell after the sub-market cards - it is unhidden and gets the
* settings of the home page element (#758: layout, customer selection).
* A page without a shell gets a copy of #758 instead.
* B3 vitec_usecaselist ("Case studies" shell, hidden as well): unhidden as
* layout testimonials. Stories = what an editor already picked on the
* shell, else the union of the market's sub-market testimonial lists,
* else the most common sub-market list. No shell: a new element after
* the logos, i.e. before the CTA banner.
* B5 the lead paragraph in the hero slot (vitec_introparagraph, colPos 1)
* is HIDDEN, not deleted (decision aehm 12.11.: copy raw material for Joe).
*
* Sub-market pages (children of the 11):
* B4 vitec_marketinsights: cta_target /success-stories -> /insights/news,
* label "Explore success stories" -> "Explore our insights" (placeholder).
*
* Idempotent: elements that already exist are left alone, filled fields are
* never overwritten. Everything goes through DataHandler (history, refindex,
* SSR auto-purge), still run cache:flush + vitec:ssr-purge/-warm afterwards -
* the command prints the exact lines.
*
* vendor/bin/typo3 vitec:markets-figma-elements # dry run (default)
* vendor/bin/typo3 vitec:markets-figma-elements --apply # writes
*/
#[AsCommand(
name: 'vitec:markets-figma-elements',
description: 'Create the Figma structure elements on the 11 main market pages (0124-FE, dry-run by default)'
)]
final class MarketsFigmaElementsCommand extends Command
{
private const MARKET_SLUGS = [
'/markets/broadcast-and-telecoms',
'/markets/corporation',
'/markets/critical-national-infrastructure',
'/markets/defense-and-security',
'/markets/education',
'/markets/government',
'/markets/hospitality',
'/markets/industrial',
'/markets/public-safety',
'/markets/sports-and-entertainment',
'/markets/transport',
];
private const LOGO_TEMPLATE_UID = 758;
private const STORY_SINGLE_PID = 26;
private const CONTACT_TARGET = '/contact';
private const LEARNMORE_LABEL = 'Talk to our team';
private const OLD_INSIGHTS_TARGET = '/success-stories';
private const NEW_INSIGHTS_TARGET = '/insights/news';
private const OLD_INSIGHTS_LABEL = 'Explore success stories';
private const NEW_INSIGHTS_LABEL = 'Explore our insights';
private const TESTIMONIALS_HEADER = 'Testimonials from the Field';
private const GENERIC_PLACEHOLDER = 'placeholders/placeholder-grey.png';
private Connection $db;
private ResourceStorage $storage;
private OutputInterface $output;
private bool $apply = false;
/** @var array<string,int> */
private array $counts = [
'images' => 0, 'targets' => 0, 'labels' => 0, 'logos' => 0, 'testimonials' => 0,
'hiddenLeads' => 0, 'insightsTargets' => 0, 'insightsLabels' => 0, 'warnings' => 0,
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write the changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$this->output = $output;
$this->apply = (bool)$input->getOption('apply');
$this->db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
$this->storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$output->writeln($this->apply ? '<comment>APPLY - writing via DataHandler</comment>' : 'DRY RUN - nothing is written (--apply to write)');
$output->writeln('');
// --- template element for the logo band ------------------------------------
$template = $this->db->fetchAssociative(
'SELECT uid, pid, CType, header, hidden, deleted FROM tt_content WHERE uid = ?',
[self::LOGO_TEMPLATE_UID]
);
if (!$template || $template['CType'] !== 'vitec_customerlogos' || (int)$template['deleted'] === 1) {
$output->writeln(sprintf('<error>Logo template #%d is not a live vitec_customerlogos element.</error>', self::LOGO_TEMPLATE_UID));
return Command::FAILURE;
}
$templateSettings = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID]));
$output->writeln(sprintf(
'Logo template: #%d "%s" (page %d) - layout %s, customers %s, onlySelected %s',
$template['uid'], $template['header'], $template['pid'],
$templateSettings['layout'] ?? '-', ($templateSettings['customers'] ?? '') !== '' ? $templateSettings['customers'] : '(all)', $templateSettings['onlySelected'] ?? '0'
));
// --- the 11 market pages -------------------------------------------------------
$markets = [];
foreach (self::MARKET_SLUGS as $slug) {
$page = $this->db->fetchAssociative(
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?',
[$slug]
);
if (!$page) {
$this->warn("page $slug NOT FOUND - skipped");
continue;
}
$markets[(int)$page['uid']] = $page;
}
if ($markets === []) {
return Command::FAILURE;
}
$marketUids = array_keys($markets);
$in = implode(',', array_map('intval', $marketUids));
// --- sub-market testimonial lists (story selection for B3) ----------------------
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$storiesByMarket = [];
$listFrequency = [];
$rows = $this->db->fetchAllAssociative(
"SELECT c.uid, c.pid, p.pid AS market, c.pi_flexform FROM tt_content c
JOIN pages p ON p.uid = c.pid
WHERE c.CType = 'vitec_usecaselist' AND c.deleted = 0 AND c.hidden = 0 AND c.sys_language_uid = 0
AND p.deleted = 0 AND p.pid IN ($in)
ORDER BY p.sorting, c.sorting"
);
foreach ($rows as $row) {
$settings = $this->flexSettings((string)$row['pi_flexform']);
$uids = array_values(array_filter(array_map('intval', explode(',', (string)($settings['usecases'] ?? '')))));
if ($output->isVerbose()) {
$output->writeln(sprintf(' list #%d (page %d, market %d): layout=%s usecases=%d', $row['uid'], $row['pid'], $row['market'], $settings['layout'] ?? '-', count($uids)));
}
if (($settings['layout'] ?? '') !== 'testimonials' || $uids === []) {
continue;
}
$market = (int)$row['market'];
foreach ($uids as $uid) {
if (!in_array($uid, $storiesByMarket[$market] ?? [], true)) {
$storiesByMarket[$market][] = $uid;
}
}
$key = implode(',', $uids);
$listFrequency[$key] = ($listFrequency[$key] ?? 0) + 1;
}
arsort($listFrequency);
$defaultStories = $listFrequency === [] ? [] : array_map('intval', explode(',', (string)array_key_first($listFrequency)));
$output->writeln(sprintf(
'Sub-market testimonial lists: %d element(s) on %d market(s); default list (most common, %dx): %s',
count($rows), count($storiesByMarket), $listFrequency === [] ? 0 : reset($listFrequency), implode(',', $defaultStories)
));
$output->writeln('');
$purgePaths = [];
// =====================================================================
// main market pages
// =====================================================================
foreach ($markets as $pid => $page) {
$output->writeln(sprintf('<info>== %s (pid %d) "%s"</info>', $page['slug'], $pid, $page['title']));
$purgePaths[] = (string)$page['slug'];
$elements = $this->db->fetchAllAssociative(
'SELECT uid, CType, colPos, sorting, hidden, header, tx_container_parent,
vitec_learnmore_label, vitec_learnmore_target
FROM tt_content
WHERE pid = ? AND deleted = 0 AND sys_language_uid = 0
ORDER BY colPos, sorting, uid',
[$pid]
);
foreach ($elements as $el) {
$output->writeln(sprintf(
' col %-3d %5d #%-6d %-22s %s%s',
$el['colPos'], $el['sorting'], $el['uid'], $el['CType'],
(int)$el['hidden'] === 1 ? '[hidden] ' : '',
$this->short((string)$el['header'])
));
}
$topLevel = array_values(array_filter($elements, static fn(array $e): bool => (int)$e['tx_container_parent'] === 0));
$live = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['hidden'] === 0));
$col0 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 0));
$col1 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 1));
$col0All = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['colPos'] === 0));
$datamap = ['tt_content' => [], 'sys_file_reference' => []];
// --- B1: intro image + learn-more target --------------------------------
$intro = $this->first($col0, 'vitec_marketintro');
if ($intro === null) {
$this->warn('no live vitec_marketintro in colPos 0 - B1 skipped');
} else {
$introUid = (int)$intro['uid'];
$refs = (int)$this->db->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_image' AND uid_foreign = ?",
[$introUid]
);
if ($refs > 0) {
$output->writeln(sprintf(' B1 intro #%d already has %d image(s) - unchanged', $introUid, $refs));
} else {
$identifier = 'placeholders/pages/' . $pid . '-02.png';
if (!$this->storage->hasFile($identifier)) {
$this->warn("$identifier missing - using generic " . self::GENERIC_PLACEHOLDER);
$identifier = self::GENERIC_PLACEHOLDER;
}
if (!$this->storage->hasFile($identifier)) {
$this->warn("$identifier missing as well - no image attached");
} else {
$file = $this->storage->getFile($identifier);
$refId = 'NEWimg' . $pid;
$datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'fieldname' => 'vitec_image',
'uid_foreign' => $introUid,
];
$datamap['tt_content'][$introUid]['vitec_image'] = $refId;
$output->writeln(sprintf(' B1 intro #%d: attach %s (sys_file %d)', $introUid, $identifier, $file->getUid()));
$this->counts['images']++;
}
}
if (trim((string)$intro['vitec_learnmore_target']) === '') {
$datamap['tt_content'][$introUid]['vitec_learnmore_target'] = self::CONTACT_TARGET;
$output->writeln(sprintf(' B1 intro #%d: learnmore_target "" -> %s', $introUid, self::CONTACT_TARGET));
$this->counts['targets']++;
if (trim((string)$intro['vitec_learnmore_label']) === '') {
$datamap['tt_content'][$introUid]['vitec_learnmore_label'] = self::LEARNMORE_LABEL;
$output->writeln(sprintf(' B1 intro #%d: learnmore_label "" -> %s', $introUid, self::LEARNMORE_LABEL));
$this->counts['labels']++;
}
} else {
$output->writeln(sprintf(' B1 intro #%d: learnmore_target already "%s" - unchanged', $introUid, $intro['vitec_learnmore_target']));
}
}
// --- B5: hide the lead paragraph in the hero slot --------------------------
$leads = array_values(array_filter($col1, static fn(array $e): bool => $e['CType'] === 'vitec_introparagraph'));
if (count($leads) !== 1) {
$this->warn(sprintf('%d live vitec_introparagraph in colPos 1 (expected 1)%s', count($leads), $leads === [] ? ' - B5 skipped' : ' - hiding all of them'));
}
foreach ($leads as $lead) {
$datamap['tt_content'][(int)$lead['uid']]['hidden'] = 1;
$output->writeln(sprintf(' B5 lead #%d "%s": hide', $lead['uid'], $this->short((string)$lead['header'])));
$this->counts['hiddenLeads']++;
}
// --- anchor: last sub-market card after the "Explore" intro paragraph -----
$anchorUid = 0;
$anchorLabel = '';
$exploreIdx = null;
foreach ($col0 as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph') {
$exploreIdx = $i;
break;
}
}
if ($exploreIdx !== null) {
$anchorUid = (int)$col0[$exploreIdx]['uid'];
$anchorLabel = 'intro paragraph #' . $anchorUid;
for ($i = $exploreIdx + 1; $i < count($col0) && $col0[$i]['CType'] === 'vitec_card'; $i++) {
$anchorUid = (int)$col0[$i]['uid'];
$anchorLabel = 'card #' . $anchorUid . ' "' . $this->short((string)$col0[$i]['header']) . '"';
}
} elseif ($intro !== null) {
$anchorUid = (int)$intro['uid'];
$anchorLabel = 'market intro #' . $anchorUid;
}
// the 2026-10-23 page import left HIDDEN shells for both bands - reuse them
$logosShell = $this->first($col0All, 'vitec_customerlogos');
$testiShell = $this->first($col0All, 'vitec_usecaselist');
$hasLogos = $logosShell !== null && (int)$logosShell['hidden'] === 0;
$hasTesti = $testiShell !== null && (int)$testiShell['hidden'] === 0;
$hasCta = $this->first($col0, 'vitec_ctabanner') !== null;
if (!$hasCta) {
$this->warn('no vitec_ctabanner in colPos 0 - the new bands will end the page');
}
// --- B2 (shell): unhide the "Trusted by" shell with the home page settings -----
if ($logosShell !== null && !$hasLogos) {
$logosUid = (int)$logosShell['uid'];
$datamap['tt_content'][$logosUid] = [
'hidden' => 0,
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => (string)($templateSettings['layout'] ?? 'marquee')],
'settings.customers' => ['vDEF' => (string)($templateSettings['customers'] ?? '')],
'settings.onlySelected' => ['vDEF' => (string)($templateSettings['onlySelected'] ?? '0')],
]]]],
];
$output->writeln(sprintf(' B2 unhide logo shell #%d "%s", settings as #%d', $logosUid, $this->short((string)$logosShell['header']), self::LOGO_TEMPLATE_UID));
$this->counts['logos']++;
}
// --- B3: testimonials -------------------------------------------------------------
// Selection: explicit lists of the market's sub-markets when they have
// any; today all 24 sub-market sliders have an EMPTY selection, which
// the plugin renders as "all stories" - the main pages do the same.
$stories = $storiesByMarket[$pid] ?? [];
$storySource = 'own sub-markets';
if ($stories === []) {
$stories = $defaultStories;
$storySource = 'most common sub-market list';
}
if ($stories === []) {
$storySource = 'empty selection = all stories, like the sub-market pages';
}
if ($hasTesti) {
$output->writeln(' B3 vitec_usecaselist already on the page - unchanged');
} elseif ($testiShell !== null) {
$testiUid = (int)$testiShell['uid'];
$current = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [$testiUid]));
$currentStories = array_values(array_filter(array_map('intval', explode(',', (string)($current['usecases'] ?? '')))));
if ($currentStories !== []) {
$stories = $currentStories;
$storySource = 'already selected on the shell';
}
{
$datamap['tt_content'][$testiUid] = [
'hidden' => 0,
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => 'testimonials'],
'settings.usecases' => ['vDEF' => implode(',', $stories)],
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
'settings.showtoolbar' => ['vDEF' => '0'],
]]]],
];
$output->writeln(sprintf(' B3 unhide shell #%d "%s" as testimonials (%s%s)', $testiUid, $this->short((string)$testiShell['header']), $storySource, $stories === [] ? '' : ': ' . implode(',', $stories)));
$this->counts['testimonials']++;
}
} elseif ($anchorUid === 0) {
$this->warn('no anchor element found - B2/B3 skipped');
} else {
$datamap['tt_content']['NEWtesti' . $pid] = [
'pid' => -$anchorUid,
'colPos' => 0,
'sys_language_uid' => 0,
'CType' => 'vitec_usecaselist',
'header' => self::TESTIMONIALS_HEADER,
'pi_flexform' => [
'data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => 'testimonials'],
'settings.usecases' => ['vDEF' => implode(',', $stories)],
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
'settings.showtoolbar' => ['vDEF' => '0'],
]]],
],
];
$output->writeln(sprintf(' B3 new vitec_usecaselist (testimonials, %s%s) after %s', $storySource, $stories === [] ? '' : ': ' . implode(',', $stories), $anchorLabel));
$this->counts['testimonials']++;
}
// --- write datamap ------------------------------------------------------------
$this->runDataHandler($datamap, []);
// --- B2 (no shell): logo band copied from #758, after the anchor (= before testimonials)
if ($hasLogos) {
$output->writeln(' B2 vitec_customerlogos already visible on the page - unchanged');
} elseif ($logosShell !== null) {
// handled above (shell unhidden in the datamap)
} elseif ($anchorUid > 0) {
$cmdmap = ['tt_content' => [self::LOGO_TEMPLATE_UID => ['copy' => [
'action' => 'paste',
'target' => -$anchorUid,
'update' => ['colPos' => 0],
]]]];
$output->writeln(sprintf(' B2 copy logo band #%d after %s', self::LOGO_TEMPLATE_UID, $anchorLabel));
$this->counts['logos']++;
$this->runDataHandler([], $cmdmap);
}
$output->writeln('');
}
// =====================================================================
// sub-market pages: insights CTA -> news
// =====================================================================
$output->writeln('<info>== sub-market pages: vitec_marketinsights CTA</info>');
$insights = $this->db->fetchAllAssociative(
"SELECT c.uid, c.pid, c.hidden, c.vitec_cta_label, c.vitec_cta_target, p.slug, p.hidden AS page_hidden
FROM tt_content c JOIN pages p ON p.uid = c.pid
WHERE c.CType = 'vitec_marketinsights' AND c.deleted = 0 AND c.sys_language_uid = 0
AND p.deleted = 0 AND p.pid IN ($in)
ORDER BY p.slug"
);
$datamap = ['tt_content' => []];
foreach ($insights as $row) {
$uid = (int)$row['uid'];
$target = trim((string)$row['vitec_cta_target']);
$label = trim((string)$row['vitec_cta_label']);
$changes = [];
if ($target === self::OLD_INSIGHTS_TARGET || $target === '') {
$datamap['tt_content'][$uid]['vitec_cta_target'] = self::NEW_INSIGHTS_TARGET;
$changes[] = sprintf('target "%s" -> %s', $target, self::NEW_INSIGHTS_TARGET);
$this->counts['insightsTargets']++;
if ($label === self::OLD_INSIGHTS_LABEL || $label === '') {
$datamap['tt_content'][$uid]['vitec_cta_label'] = self::NEW_INSIGHTS_LABEL;
$changes[] = sprintf('label "%s" -> %s', $label, self::NEW_INSIGHTS_LABEL);
$this->counts['insightsLabels']++;
} else {
$changes[] = sprintf('label "%s" kept (not the standard text)', $label);
}
$purgePaths[] = (string)$row['slug'];
} elseif ($target === self::NEW_INSIGHTS_TARGET) {
$changes[] = 'already ' . self::NEW_INSIGHTS_TARGET;
} else {
$changes[] = sprintf('target "%s" is individual - unchanged', $target);
}
$output->writeln(sprintf(' #%-6d %-60s %s%s', $uid, $row['slug'], (int)$row['hidden'] === 1 || (int)$row['page_hidden'] === 1 ? '[hidden] ' : '', implode('; ', $changes)));
}
$this->runDataHandler($datamap, []);
$output->writeln('');
// =====================================================================
$output->writeln(sprintf(
'%s: %d intro image(s), %d learnmore target(s) (+%d label), %d logo band(s), %d testimonial slider(s), %d lead paragraph(s) hidden, %d insights target(s) (+%d label); %d warning(s).',
$this->apply ? 'DONE' : 'DRY RUN - would write',
$this->counts['images'], $this->counts['targets'], $this->counts['labels'], $this->counts['logos'], $this->counts['testimonials'],
$this->counts['hiddenLeads'], $this->counts['insightsTargets'], $this->counts['insightsLabels'], $this->counts['warnings']
));
$purgePaths = array_values(array_unique($purgePaths));
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge ' . implode(' ', $purgePaths) . ' && vendor/bin/typo3 vitec:ssr-warm ' . implode(' ', $purgePaths));
return Command::SUCCESS;
}
/** @param array<string,array<string|int,array<string,mixed>>> $datamap */
private function runDataHandler(array $datamap, array $cmdmap): void
{
$datamap = array_filter($datamap);
if (!$this->apply || ($datamap === [] && $cmdmap === [])) {
return;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, $cmdmap);
if ($datamap !== []) {
$dataHandler->process_datamap();
}
if ($cmdmap !== []) {
$dataHandler->process_cmdmap();
}
if ($dataHandler->errorLog !== []) {
$this->warn('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
$this->output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
}
foreach ($dataHandler->copyMappingArray_merged['tt_content'] ?? [] as $from => $to) {
$this->output->writeln(sprintf(' copied: #%d -> #%d', $from, $to));
}
}
/** @return array<string,string> flat "settings" sheet of a pi_flexform XML (empty when none) */
private function flexSettings(string $xml): array
{
if (trim($xml) === '') {
return [];
}
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
}
/** @param array<int,array<string,mixed>> $elements */
private function first(array $elements, string $cType): ?array
{
foreach ($elements as $el) {
if ($el['CType'] === $cType) {
return $el;
}
}
return null;
}
private function warn(string $message): void
{
$this->counts['warnings']++;
$this->output->writeln(' <comment>WARN ' . $message . '</comment>');
}
private function short(string $text): string
{
$text = trim((string)preg_replace('/\s+/', ' ', strip_tags($text)));
return mb_strlen($text) > 60 ? mb_substr($text, 0, 57) . '...' : $text;
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* Puts the project's recurring jobs into the TYPO3 Scheduler (group
* "VITEC"), so they are visible, startable and switchable in
* System › Scheduler instead of hiding in the crontab. The only cron
* line left on dedi5092 is the core "scheduler:run" every minute.
*
* Idempotent: a task whose command already exists in the scheduler is
* left alone. --disabled creates new tasks switched off (use while the
* old crontab lines still run, so nothing runs twice), --enable switches
* the VITEC tasks on afterwards.
*/
#[AsCommand(
name: 'vitec:scheduler-setup',
description: 'Create the VITEC scheduler tasks (SSR health every 5 min, client error cleanup daily) in the group "VITEC"; safe to run again'
)]
final class SchedulerSetupCommand extends Command
{
private const GROUP = 'VITEC';
/** command => [cron expression, description] */
private const TASKS = [
'vitec:ssr-health' => [
'*/5 * * * *',
'SSR health check: samples /__health and /__stats of the SSR node into the health log (cockpit charts), '
. 'mails the alert recipients from Site settings › VITEC on down/up changes and falls back to the SPA '
. 'after repeated failures. Formerly crontab.',
],
'vitec:client-error-cleanup' => [
'23 4 * * *',
'Deletes browser error reports (client errors from the React frontend) older than 30 days. Formerly crontab.',
],
];
public function __construct(
private readonly TaskService $taskService,
private readonly SchedulerTaskRepository $taskRepository,
private readonly ConnectionPool $connectionPool,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('disabled', null, InputOption::VALUE_NONE, 'Create new tasks switched off');
$this->addOption('enable', null, InputOption::VALUE_NONE, 'Switch on all VITEC tasks created by this command');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$groupUid = $this->groupUid();
$output->writeln('Scheduler group "' . self::GROUP . '": uid ' . $groupUid);
foreach (self::TASKS as $command => [$cron, $description]) {
$existing = $this->findTask($command);
if ($existing !== null) {
if ($input->getOption('enable') && (int)$existing['disable'] === 1) {
$task = $this->taskRepository->findByUid((int)$existing['uid']);
$task->setDisabled(false);
$this->taskRepository->update($task);
$output->writeln(sprintf('<info>%s: enabled (uid %d)</info>', $command, $existing['uid']));
} else {
$output->writeln(sprintf('%s: exists (uid %d, %s)', $command, $existing['uid'],
(int)$existing['disable'] === 1 ? 'disabled' : 'enabled'));
}
continue;
}
if (!$this->taskService->isTaskTypeRegistered($command)) {
$output->writeln('<error>' . $command . ': not available as scheduler task type</error>');
continue;
}
$task = $this->taskService->createNewTask($command);
$task->setDescription($description);
$task->setTaskGroup($groupUid);
$task->setExecution(Execution::createRecurringExecution(time() - 60, 0, 0, false, $cron));
$task->setDisabled((bool)$input->getOption('disabled'));
if ($this->taskRepository->add($task)) {
$output->writeln(sprintf('<info>%s: created (uid %d, %s, %s)</info>', $command, $task->getTaskUid(), $cron,
$task->isDisabled() ? 'disabled' : 'enabled'));
} else {
$output->writeln('<error>' . $command . ': could not be created</error>');
}
}
return Command::SUCCESS;
}
private function groupUid(): int
{
$connection = $this->connectionPool->getConnectionForTable('tx_scheduler_task_group');
$uid = $connection->executeQuery(
'SELECT uid FROM tx_scheduler_task_group WHERE groupName = ? AND deleted = 0 ORDER BY uid LIMIT 1',
[self::GROUP]
)->fetchOne();
if ($uid !== false) {
return (int)$uid;
}
$sorting = (int)$connection->executeQuery('SELECT MAX(sorting) FROM tx_scheduler_task_group')->fetchOne();
$connection->insert('tx_scheduler_task_group', [
'pid' => 0,
'tstamp' => time(),
'crdate' => time(),
'groupName' => self::GROUP,
'color' => '#f47937',
'description' => 'Recurring jobs of the VITEC website (SSR monitoring, cleanups)',
'sorting' => $sorting + 256,
], ['pid' => Connection::PARAM_INT, 'tstamp' => Connection::PARAM_INT, 'crdate' => Connection::PARAM_INT, 'sorting' => Connection::PARAM_INT]);
return (int)$connection->lastInsertId();
}
private function findTask(string $command): ?array
{
$row = $this->connectionPool->getConnectionForTable('tx_scheduler_task')->executeQuery(
'SELECT uid, disable FROM tx_scheduler_task WHERE tasktype = ? AND deleted = 0 ORDER BY uid LIMIT 1',
[$command]
)->fetchAssociative();
return $row === false ? null : $row;
}
}
@@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Seeds the "Why choose <product>?" cards (tx_vitec_product_feature,
* contract 0036-FE) from the `applications` RTE HTML plus the staged agency
* icons:
*
* - `applications` pairs `<p><strong>Title</strong></p><p>Text</p>` become
* one feature record each (several text paragraphs are joined); the
* leading <h3> stays where it is - the JSON heading is derived from it.
* - icons come from fileadmin/icons/delivered/products/<context>/ (the
* 2026-11-03 staging); <context> is the product slug unless ICON_DIRS
* maps it. Matching: normalised title vs. the filename theme (the part
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
* - a product that already HAS feature records is never touched - after
* the seed the editors own the cards. EXCEPT with --attach-icons
* (2026-11-10, later icon deliveries such as PRISM/SFF PRISM/Avedia
* chassis): then its cards WITHOUT an icon get the matching staged icon
* (same matcher); titles, texts and cards with an icon stay untouched.
*
* Unmatched icons and unmatched cards are reported so the editors can map
* the leftovers by hand (0036-FE asks for exactly that list).
*
* vendor/bin/typo3 vitec:seed-product-features
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
* vendor/bin/typo3 vitec:seed-product-features --only=prism --attach-icons --apply
*/
#[AsCommand(
name: 'vitec:seed-product-features',
description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)'
)]
final class SeedProductFeaturesCommand extends Command
{
private const TABLE = 'tx_vitec_product_feature';
private const ICON_BASE = 'icons/delivered/products/';
/** product slug => staging context where the names differ */
private const ICON_DIRS = [
'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform',
'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform',
'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product',
];
private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
$this->addOption('attach-icons', null, InputOption::VALUE_NONE, 'Products with existing cards: attach staged icons to cards without one');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$attachIcons = (bool)$input->getOption('attach-icons');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)'
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)');
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$seeded = 0;
$cards = 0;
$iconsMatched = 0;
$skippedExisting = 0;
$skippedEmpty = 0;
$attached = 0;
foreach ($products as $product) {
$uid = (int)$product['uid'];
$slug = (string)$product['slug'];
if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) {
continue;
}
$existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne(
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
[$uid]
);
if ($existing > 0 && !$attachIcons) {
$skippedExisting++;
continue;
}
// staged icons of this product, filename theme => identifier
$context = self::ICON_DIRS[$slug] ?? $slug;
$iconByTheme = [];
$folder = self::ICON_BASE . $context;
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) !== 'svg') {
continue;
}
if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$iconByTheme[$m[1]] = $file->getIdentifier();
}
}
}
if ($existing > 0) {
// --attach-icons: existing cards without an icon get a staged one
if ($iconByTheme === []) {
$skippedExisting++;
continue;
}
$bare = $pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
'SELECT f.uid, f.title FROM ' . self::TABLE . ' f WHERE f.deleted = 0 AND f.product = ?
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = ?
AND r.fieldname = ? AND r.uid_foreign = f.uid)
ORDER BY f.sorting',
[$uid, self::TABLE, 'icon']
);
if ($bare === []) {
$skippedExisting++;
continue;
}
$pairs = array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title'], 'text' => '-'], $bare);
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - ATTACH: %d existing card(s) without icon, %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
foreach ($pairs as $pair) {
$output->writeln(' "' . $pair['title'] . '"' . (isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$iconsMatched += count($usedThemes);
$attached += count($usedThemes);
if (!$apply || $usedThemes === []) {
continue;
}
$datamap = [];
foreach ($pairs as $pair) {
if (!isset($pair['icon'])) {
continue;
}
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => (int)$product['pid'],
'table_local' => 'sys_file',
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
];
$datamap[self::TABLE][(string)$pair['uid']] = ['icon' => $refId];
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
continue;
}
$pairs = $this->parseApplications((string)$product['applications']);
if ($pairs === []) {
$skippedEmpty++;
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
foreach ($pairs as $pair) {
$output->writeln(sprintf(
' "%s"%s%s',
$pair['title'],
$pair['text'] !== '' ? '' : ' <comment>(no text)</comment>',
isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'
));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$cards += count($pairs);
$iconsMatched += count($usedThemes);
$seeded++;
if (!$apply) {
continue;
}
$datamap = [];
$featureIds = [];
foreach ($pairs as $pair) {
$featureId = $this->newId();
$record = [
'pid' => (int)$product['pid'],
'product' => $uid,
'title' => $pair['title'],
'cardtext' => $pair['text'],
];
if (isset($pair['icon'])) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => (int)$product['pid'],
'table_local' => 'sys_file',
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
];
$record['icon'] = $refId;
}
$datamap[self::TABLE][$featureId] = $record;
$featureIds[] = $featureId;
}
$datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// DataHandler puts NEW records at the TOP - write explicit sorting
$sorting = 0;
foreach ($featureIds as $featureId) {
$realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0);
if ($realUid > 0) {
$sorting += 256;
$pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s</info>",
$seeded, $cards, $iconsMatched, $attachIcons ? ', ' . $attached . ' attached to existing cards' : '', $skippedExisting, $skippedEmpty,
$apply ? '' : ' (dry run - nothing written)'
));
if ($apply && ($seeded > 0 || $attached > 0)) {
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
}
return Command::SUCCESS;
}
/**
* The `<p><strong>Title</strong></p><p>Text…</p>` pairs of the
* applications HTML, in order. Several text paragraphs after one title
* are joined; the <h3> heading and empty paragraphs are skipped.
*
* @return array<int,array{title:string,text:string,icon?:string}>
*/
private function parseApplications(string $html): array
{
if (trim($html) === '') {
return [];
}
$pairs = [];
$current = null;
if (!preg_match_all('#<p[^>]*>(.*?)</p>#is', $html, $matches)) {
return [];
}
foreach ($matches[1] as $inner) {
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($plain === '') {
continue;
}
$strongOnly = preg_match('#^\s*<strong[^>]*>(.*?)</strong>\s*$#is', trim($inner), $m)
? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')))
: null;
if ($strongOnly !== null && $strongOnly !== '') {
if ($current !== null) {
$pairs[] = $current;
}
$current = ['title' => $strongOnly, 'text' => ''];
continue;
}
if ($current !== null) {
$current['text'] = trim($current['text'] . ' ' . $plain);
}
}
if ($current !== null) {
$pairs[] = $current;
}
return $pairs;
}
/**
* Matches the staged icons onto the cards (three passes: normalised title
* / Levenshtein, token containment, last pair standing). Sets 'icon' on
* the matched pairs and returns the used themes.
*
* @param array<int,array{title:string,text:string,icon?:string,uid?:int}> $pairs
* @param array<string,string> $iconByTheme theme => identifier
* @return array<string,bool>
*/
private function matchIcons(array &$pairs, array $iconByTheme): array
{
$usedThemes = [];
foreach ($pairs as $i => $pair) {
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
if ($theme !== null) {
$pairs[$i]['icon'] = $iconByTheme[$theme];
$usedThemes[$theme] = true;
}
}
// second pass: token containment, e.g. theme "ManagementFoundation"
// inside the card title "Aetria management foundation"
foreach ($pairs as $i => $pair) {
if (isset($pair['icon'])) {
continue;
}
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
$titleTokens = $this->tokens($pair['title']);
$themeTokens = $this->tokens($theme);
if ($titleTokens !== [] && $themeTokens !== []
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
) {
$pairs[$i]['icon'] = $iconByTheme[$theme];
$usedThemes[$theme] = true;
break;
}
}
}
// last pair standing: a complete set (#icons == #cards) with exactly
// one icon and one card left over - they belong to each other
if (count($iconByTheme) === count($pairs)) {
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
if (count($freeThemes) === 1 && count($freePairs) === 1) {
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
$usedThemes[$freeThemes[0]] = true;
}
}
return $usedThemes;
}
/**
* The icon theme that matches a card title: exact on the normalised
* strings (stop words removed on both sides), else the closest theme
* within Levenshtein distance 3 (typo tolerance).
*
* @param array<string,string> $iconByTheme theme => identifier
*/
private function matchTheme(string $title, array $iconByTheme): ?string
{
if ($iconByTheme === []) {
return null;
}
$titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]);
$best = null;
$bestDistance = PHP_INT_MAX;
foreach (array_keys($iconByTheme) as $theme) {
$themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]);
foreach ($titleNorms as $a) {
foreach ($themeNorms as $b) {
if ($a === $b) {
return $theme;
}
if (abs(strlen($a) - strlen($b)) <= 3) {
$distance = levenshtein($a, $b);
if ($distance < $bestDistance) {
$bestDistance = $distance;
$best = $theme;
}
}
}
}
}
return $bestDistance <= 3 ? $best : null;
}
/**
* Lowercase word list (camelCase split, stop words removed) for the
* token-containment pass.
*
* @return string[]
*/
private function tokens(string $value): array
{
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$words = array_map(static fn(string $w): string => mb_strtolower($w), $words);
return array_values(array_filter($words, static fn(string $w): bool => !in_array($w, self::STOPWORDS, true)));
}
/** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */
private function norm(string $value, bool $dropStopwords): string
{
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($dropStopwords) {
$words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true));
}
return mb_strtolower(implode('', $words));
}
private function newId(): string
{
return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Shows (and on --sync repairs) the SSR routing state: the Site Set
* switch "vitec.ssr.enabled" versus the flag file that .htaccess
* evaluates. The SyncSsrFlag middleware does the same sync on every
* request, so this command is mainly for diagnostics and scripting.
*
* vendor/bin/typo3 vitec:ssr-flag # status only
* vendor/bin/typo3 vitec:ssr-flag --sync # align flag with setting
*/
#[AsCommand(name: 'vitec:ssr-flag', description: 'Show/sync the SSR routing flag against the vitec.ssr.enabled site setting')]
final class SsrFlagCommand extends Command
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('sync', null, InputOption::VALUE_NONE, 'Create/remove the flag file so it matches the setting');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$setting = $this->ssrFlagService->isEnabledInSettings();
$flag = $this->ssrFlagService->isFlagSet();
$output->writeln('Setting vitec.ssr.enabled: ' . ($setting ? 'ON' : 'off'));
$output->writeln('Flag ' . $this->ssrFlagService->getFlagPath() . ': ' . ($flag ? 'EXISTS (SSR routing active)' : 'absent (SPA as before)'));
if ($setting === $flag) {
$output->writeln('<info>In sync.</info>');
return Command::SUCCESS;
}
if (!$input->getOption('sync')) {
$output->writeln('<comment>OUT OF SYNC - run with --sync (or just hit any page: the middleware repairs it).</comment>');
return Command::FAILURE;
}
$new = $this->ssrFlagService->sync();
$output->writeln('<info>Synced: flag is now ' . ($new ? 'SET - SSR routing active.' : 'removed - SPA as before.') . '</info>');
return Command::SUCCESS;
}
}
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Evomedien\Vitec\Service\SsrPurgeService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
#[AsCommand(
name: 'vitec:ssr-health',
description: 'Sample the SSR node /__health into tx_vitec_ssrhealthlog; mails on ok/down transitions, auto-failover to SPA after N consecutive misses',
)]
final class SsrHealthSampleCommand extends Command
{
private const KEEP_DAYS = 14;
public function __construct(
private readonly SsrPurgeService $purgeService,
private readonly SsrFlagService $flagService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'test-mail',
null,
InputOption::VALUE_OPTIONAL,
'Send a sample alert mail through the real alert path and exit (no health sample). Optional address; default: the configured alert recipients',
false
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$testMail = $input->getOption('test-mail');
if ($testMail !== false) {
return $this->sendTestMail((string)$testMail, $output);
}
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
$output->writeln('<error>SSR_HEALTH_ENDPOINT missing in var/vitec-ssr-purge.env</error>');
return Command::INVALID;
}
$row = ['crdate' => time(), 'ok' => 0, 'release_id' => '', 'cached_pages' => 0, 'cache_mb' => 0, 'response_ms' => 0];
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
]);
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
if ($response->getStatusCode() === 200) {
$data = json_decode((string)$response->getBody(), true);
$row['ok'] = !empty($data['ok']) ? 1 : 0;
$row['release_id'] = substr((string)($data['release']['release'] ?? ''), 0, 100);
$row['cached_pages'] = (int)($data['cachedPages'] ?? 0);
$row['cache_mb'] = (int)($data['cacheMB'] ?? 0);
}
} catch (\Throwable $e) {
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
}
// Cumulative node counters from /__stats (-1 = no data this sample,
// e.g. node down or release without the endpoint). The errors tile
// computes 24h deltas from these, skipping -1 rows; the counters
// reset whenever the node restarts, which the delta logic clamps.
$stats = [
'stat_hit' => -1, 'stat_miss' => -1, 'stat_stale' => -1,
'stat_bypass' => -1, 'stat_render_errors' => -1, 'stat_backend_errors' => -1,
];
if ($row['ok'] === 1 && ($statsEndpoint = $this->purgeService->getStatsEndpoint()) !== '') {
try {
$statsResponse = $this->requestFactory->request($statsEndpoint, 'GET', [
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($statsResponse->getStatusCode() === 200) {
$s = json_decode((string)$statsResponse->getBody(), true) ?: [];
$stats = [
'stat_hit' => (int)($s['hit'] ?? -1),
'stat_miss' => (int)($s['miss'] ?? -1),
'stat_stale' => (int)($s['stale'] ?? -1),
'stat_bypass' => (int)($s['bypass'] ?? -1),
'stat_render_errors' => (int)($s['renderErrors'] ?? -1),
'stat_backend_errors' => (int)($s['backendErrors'] ?? -1),
];
}
} catch (\Throwable) {
// Stats are optional - the health sample must never fail on them.
}
}
$row = array_merge($row, $stats);
// Previous samples, newest first, read BEFORE this one is inserted:
// [0] is the state every transition decision compares against.
$failoverAfter = $this->purgeService->getFailoverAfter();
$previous = array_map('intval', $this->connectionPool
->getQueryBuilderForTable('tx_vitec_ssrhealthlog')
->select('ok')->from('tx_vitec_ssrhealthlog')
->orderBy('crdate', 'DESC')->addOrderBy('uid', 'DESC')
->setMaxResults($failoverAfter)
->executeQuery()->fetchFirstColumn());
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$conn->insert('tx_vitec_ssrhealthlog', $row);
$conn->executeStatement(
'DELETE FROM tx_vitec_ssrhealthlog WHERE crdate < ?',
[time() - self::KEEP_DAYS * 86400]
);
$output->writeln(sprintf(
'%s release=%s pages=%d cache=%dMB %dms',
$row['ok'] ? '<info>ok</info>' : '<error>DOWN</error>',
$row['release_id'] ?: '-',
$row['cached_pages'],
$row['cache_mb'],
$row['response_ms']
));
if ($row['stat_hit'] >= 0) {
$output->writeln(sprintf(
'stats: hit=%d miss=%d stale=%d bypass=%d renderErrors=%d backendErrors=%d',
$row['stat_hit'],
$row['stat_miss'],
$row['stat_stale'],
$row['stat_bypass'],
$row['stat_render_errors'],
$row['stat_backend_errors']
));
}
$prevOk = $previous === [] ? null : $previous[0];
if ($prevOk !== null && $prevOk !== $row['ok']) {
if ($row['ok'] === 1) {
$this->sendAlert(
'[VITEC SSR] Recovered: SSR server reachable again',
"The 5-minute health check reaches the SSR server again (status ok).\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Release: ' . ($row['release_id'] ?: '-') . "\n"
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
. 'Current state: switch vitec.ssr.enabled = '
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n"
. "If the auto-failover was triggered, the site deliberately stays on SPA.\n"
. "Re-enable: TYPO3 backend > Site Management > Settings > SSR delivery ON,\n"
. "then a full purge (vendor/bin/typo3 vitec:ssr-purge --all).\n",
$output
);
} else {
$this->sendAlert(
'[VITEC SSR] ALERT: SSR server not responding',
"The 5-minute health check could not reach the SSR server\n"
. "(or /__health reported an error).\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Endpoint: ' . $endpoint . "\n"
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
. 'As long as the flag file exists, dev.vitec.com keeps routing to the SSR server. After '
. $failoverAfter . ' failed checks in a row (about ' . ($failoverAfter * 5) . " minutes) the\n"
. "auto-failover switches to SPA delivery automatically - the site stays reachable.\n\n"
. "This mail is sent only on the ok -> down transition, not for every check.\n",
$output
);
}
}
if ($row['ok'] === 0 && $this->flagService->isFlagSet()) {
$consecutive = 1;
foreach ($previous as $ok) {
if ($ok !== 0) {
break;
}
$consecutive++;
}
if ($consecutive >= $failoverAfter) {
try {
$this->flagService->disableSsr();
$output->writeln(sprintf(
'<error>AUTO-FAILOVER after %d consecutive misses: vitec.ssr.enabled=false, caches flushed, flag removed</error>',
$consecutive
));
$this->sendAlert(
'[VITEC SSR] AUTO-FAILOVER: site switched to SPA',
'AUTO-FAILOVER TRIGGERED: ' . $consecutive . " consecutive DOWN checks.\n\n"
. "dev.vitec.com now serves the SPA (rendering in the browser).\n"
. "The site stays available for visitors.\n\n"
. "What happened automatically:\n"
. "- site setting vitec.ssr.enabled set to false (settings.yaml)\n"
. "- caches flushed\n"
. "- flag file var/vitec-ssr-on.flag removed\n\n"
. "Re-enable (deliberately MANUAL only) once the SSR server is back:\n"
. "1. Check on the VPS: systemctl status vitec-ssr and /__health\n"
. "2. TYPO3 backend > Site Management > Settings > SSR delivery back ON\n"
. "3. Full purge: vendor/bin/typo3 vitec:ssr-purge --all\n",
$output
);
} catch (\Throwable $e) {
$output->writeln('<error>auto-failover FAILED: ' . $e->getMessage() . '</error>');
$this->sendAlert(
'[VITEC SSR] ERROR: auto-failover FAILED',
"The auto-failover should have triggered but failed:\n"
. $e->getMessage() . "\n\n"
. "Please check manually: site setting vitec.ssr.enabled, file var/vitec-ssr-on.flag.\n"
. "Emergency off without TYPO3: delete the flag file AND set the setting to false\n"
. "(otherwise the SyncSsrFlag middleware recreates the flag file immediately).\n",
$output
);
}
}
}
return Command::SUCCESS;
}
/**
* Deliverability check (e.g. mail-tester.com): same sender, mailer and
* plain-text format as a real alert, clearly marked as a test.
*/
private function sendTestMail(string $address, OutputInterface $output): int
{
$override = [];
if ($address !== '') {
if (!GeneralUtility::validEmail($address)) {
$output->writeln('<error>invalid address: ' . $address . '</error>');
return Command::INVALID;
}
$override = [$address];
}
$this->sendAlert(
'[VITEC SSR] TEST: alert mail check',
"This is a test of the VITEC SSR alert mail - nothing is wrong.\n\n"
. "Real alerts look like this one and are sent when the 5-minute health\n"
. "check sees the SSR server go down or come back, and when the\n"
. "auto-failover switches the site to SPA delivery.\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Current state: switch vitec.ssr.enabled = '
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n\n"
. "Recipients are maintained in the TYPO3 backend:\n"
. "Site Management > Settings > VITEC > SSR alert mail recipients.\n",
$output,
$override
);
return Command::SUCCESS;
}
/**
* Mails are best effort: a mail problem must never break the sampler
* (the cron would stop logging history). Recipients come from the site
* setting vitec.ssr.alertEmails (Site Management › Settings › VITEC),
* fallback SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - none at all
* means "alerts off", which the output states so the cron log shows why.
*/
private function sendAlert(string $subject, string $body, OutputInterface $output, array $override = []): void
{
$alert = $this->purgeService->getAlertRecipients();
if ($alert['invalid'] !== [] && $override === []) {
$output->writeln('<comment>skipped invalid alert addresses: ' . implode(', ', $alert['invalid']) . '</comment>');
}
$recipients = $override !== [] ? $override : $alert['recipients'];
if ($recipients === []) {
$output->writeln('<comment>transition detected but no alert recipients are configured (Site settings › VITEC or SSR_ALERT_EMAIL) - no mail sent</comment>');
return;
}
try {
$mail = GeneralUtility::makeInstance(MailMessage::class);
$mail->to(...$recipients)->subject($subject)->text($body);
GeneralUtility::makeInstance(Mailer::class)->send($mail);
$output->writeln('alert mail sent: ' . $subject . ' -> ' . implode(', ', $recipients));
} catch (\Throwable $e) {
$output->writeln('<error>alert mail failed: ' . $e->getMessage() . '</error>');
}
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'vitec:ssr-purge',
description: 'Purge paths (or everything with --all) from the SSR node page cache',
)]
final class SsrPurgeCommand extends Command
{
public function __construct(private readonly SsrPurgeService $purgeService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to purge, e.g. /products/aligo');
$this->addOption('all', null, InputOption::VALUE_NONE, 'Purge the whole cache ("*")');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$paths = $input->getOption('all') ? ['*'] : (array)$input->getArgument('paths');
if ($paths === []) {
$output->writeln('<error>No paths given (use arguments or --all).</error>');
return Command::INVALID;
}
$result = $this->purgeService->purge($paths, 'cli');
$output->writeln(sprintf(
'%s HTTP %d - %s (paths: %s)',
$result['success'] ? '<info>OK</info>' : '<error>FAILED</error>',
$result['httpCode'],
$result['message'],
implode(', ', $result['paths'])
));
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Read-only: shows which SSR paths the auto-purge would clear when the
* given record is saved (SsrPurgeTargetResolver). Purges nothing.
*
* vitec:ssr-purge-targets tx_vitec_domain_model_product 12
* vitec:ssr-purge-targets tx_news_domain_model_news 300 --fields=title,bodytext
*/
#[AsCommand(
name: 'vitec:ssr-purge-targets',
description: 'Show which SSR paths the auto-purge would clear for a saved record (read-only, purges nothing)'
)]
final class SsrPurgeTargetsCommand extends Command
{
protected function configure(): void
{
$this->addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product');
$this->addArgument('uid', InputArgument::REQUIRED, 'Record uid');
$this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true);
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields);
if ($paths === []) {
$output->writeln('nothing to purge (record not rendered anywhere)');
} elseif ($paths === [SsrPurgeTargetResolver::ALL]) {
$output->writeln('<comment>* (everything)</comment>');
} else {
$output->writeln(count($paths) . ' path(s):');
foreach ($paths as $path) {
$output->writeln(' ' . $path);
}
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'vitec:ssr-warm',
description: 'Start a background warm run on the SSR node (all sitemap pages, or the given paths)',
)]
final class SsrWarmCommand extends Command
{
public function __construct(private readonly SsrPurgeService $purgeService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to warm; none = every page of sitemap.xml');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$result = $this->purgeService->warm((array)$input->getArgument('paths'));
$output->writeln(($result['success'] ? '<info>OK</info> ' : '<error>FAILED</error> ') . $result['message']);
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\ContextMenu;
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\AbstractProvider;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Environment;
/**
* Page tree context menu: "Purge from SSR cache" - evicts exactly this
* page from the SSR node's full-page cache, with toast feedback.
* Shown only for real pages and only while the purge endpoint is
* configured (var/vitec-ssr-purge.env).
*/
final class SsrPurgeItemProvider extends AbstractProvider
{
protected $itemsConfiguration = [
'ssrPurge' => [
'type' => 'item',
'label' => 'Purge from SSR cache',
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
'callbackAction' => 'ssrPurge',
],
];
public function canHandle(): bool
{
return $this->table === 'pages';
}
/**
* Below the core PageProvider (100): we run after it and splice our
* item into the finished menu, right behind "Clear cache".
*/
public function getPriority(): int
{
return 42;
}
public function addItems(array $items): array
{
if (!is_readable(Environment::getVarPath() . '/vitec-ssr-purge.env')) {
return $items;
}
$page = BackendUtility::getRecord('pages', (int)$this->identifier, 'uid,doktype');
if ($page === null || (int)$page['doktype'] >= 199) {
// Spacers, folders, recycler - nothing cached for those.
return $items;
}
$this->initDisabledItems();
$localItems = $this->prepareItems($this->itemsConfiguration);
if (isset($items['clearCache'])) {
$new = [];
foreach ($items as $key => $item) {
$new[$key] = $item;
if ($key === 'clearCache') {
$new = array_merge($new, $localItems);
}
}
return $new;
}
return array_merge($items, $localItems);
}
protected function getAdditionalAttributes(string $itemName): array
{
// No ".js" here: the context menu JS appends it when importing.
return ['data-callback-module' => '@evomedien/vitec/ssr-context-menu'];
}
}
@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use Evomedien\Vitec\Service\SsrPurgeService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend module "VITEC SSR": live state of the SSR node, manual purge
* (single paths or everything) and the purge/health protocol.
*/
#[AsController]
final class SsrController
{
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly FlashMessageService $flashMessageService,
private readonly UriBuilder $uriBuilder,
private readonly PageRenderer $pageRenderer,
private readonly ComponentFactory $componentFactory,
) {}
public function indexAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle('VITEC SSR');
[$health, $healthError] = $this->fetchHealth();
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-cockpit.js');
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-module.js');
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-ssr.css');
// DocHeader: shortcut button (0096-FE); the reload button comes from the core.
$view->addButtonToButtonBar(
$this->componentFactory->createShortcutButton()
->setRouteIdentifier('web_vitecssr')
->setDisplayName('VITEC SSR'),
ButtonBar::BUTTON_POSITION_RIGHT
);
$limit = (int)($request->getQueryParams()['limit'] ?? 30);
if (!in_array($limit, [30, 100, 500], true)) {
$limit = 30;
}
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
$lastSample = $healthLog[0] ?? null;
$errorReport = $this->clientErrors(50);
$clientErrors = $errorReport['list'];
$cockpit = $this->cockpitData($health, $lastSample);
$alert = $this->purgeService->getAlertRecipients();
$settingsUri = '';
if ($GLOBALS['BE_USER']->isAdmin()) {
try {
$sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites();
$site = reset($sites);
if ($site) {
$settingsUri = (string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
['site' => $site->getIdentifier()]
);
}
} catch (\Throwable) {
$settingsUri = '';
}
}
$view->assignMultiple([
'cockpit' => $cockpit,
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
'settingsUri' => $settingsUri,
'failoverAfter' => $this->purgeService->getFailoverAfter(),
'alert' => $alert,
'alertList' => implode(', ', $alert['recipients']),
'alertCount' => count($alert['recipients']),
'alertInvalid' => implode(', ', $alert['invalid']),
'chunkCount24h' => $errorReport['chunk'],
'jsErrorCount24h' => $errorReport['js'],
'testCount24h' => $errorReport['tests'],
'botCount24h' => $errorReport['bots'],
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
'configured' => $this->purgeService->isConfigured(),
'health' => $health,
'healthError' => $healthError,
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
'lastSample' => $lastSample,
'lastSampleAge' => $lastSample ? BackendUtility::calcAge(time() - (int)$lastSample['crdate']) : '',
'purgeLog' => $this->purgeRows($limit),
'purgeLimit' => $limit,
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
'healthLog' => $healthLog,
'clientErrors' => $clientErrors,
'clientErrorCount24h' => $errorReport['real'],
]);
return $view->renderResponse('Ssr/Index');
}
/**
* Client error reports of the last 24 hours (POST /api/vitec/client-error,
* ClientErrorMiddleware), newest first. Test reports (smoke tests, curl)
* are counted apart and left out of the list (0114-FE); bot reports stay
* in with a label. Rows carry a "path" and a shortened user agent for the
* list; the full values stay in the row for the details toggle.
*
* @return array{list: list<array>, real: int, chunk: int, js: int, tests: int, bots: int}
*/
private function clientErrors(int $limit): array
{
$report = ['list' => [], 'real' => 0, 'chunk' => 0, 'js' => 0, 'tests' => 0, 'bots' => 0];
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog');
$rows = $qb->select('*')->from('tx_vitec_clienterrorlog')
->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT)))
->orderBy('uid', 'DESC')->setMaxResults(2000)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return $report;
}
foreach ($rows as $row) {
$ua = (string)$row['user_agent'];
if (ClientErrorClassifier::isTest((string)$row['message'], $ua, (string)$row['release'])) {
$report['tests']++;
continue;
}
$row['is_chunk_reload'] = ClientErrorClassifier::isChunkReload((string)$row['message']);
$row['is_bot'] = ClientErrorClassifier::isBot($ua);
$report['real']++;
$report[$row['is_chunk_reload'] ? 'chunk' : 'js']++;
$report['bots'] += $row['is_bot'] ? 1 : 0;
if (count($report['list']) < $limit) {
$path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? '');
$row['path'] = $path !== '' ? $path : (string)$row['url'];
$row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua;
$report['list'][] = $row;
}
}
return $report;
}
/**
* Purge log rows prepared for the template: trigger split into a short
* label plus the user, paths as count + first two + the rest behind a
* disclosure (the raw list was a wall of text, 0096-FE).
*/
private function purgeRows(int $limit): array
{
$rows = $this->rows('tx_vitec_ssrpurgelog', $limit);
$today = date('Y-m-d');
$yesterday = date('Y-m-d', time() - 86400);
$lastDay = '';
foreach ($rows as &$row) {
// Day separators for the activity timeline.
$day = date('Y-m-d', (int)$row['crdate']);
$row['day_label'] = match ($day) {
$today => 'Today',
$yesterday => 'Yesterday',
default => date('D, d.m.', (int)$row['crdate']),
};
$row['day_first'] = $day !== $lastDay;
$lastDay = $day;
$trigger = (string)$row['trigger_source'];
$who = '';
if (str_contains($trigger, ':')) {
[$trigger, $who] = explode(':', $trigger, 2);
}
$row['trigger_label'] = match ($trigger) {
'module' => 'manual',
'contextmenu' => 'context menu',
'editor-save', 'editor-command' => 'auto-purge',
default => $trigger,
};
$row['trigger_who'] = $who;
// Cockpit tone per trigger (pill + timeline dot colour).
$row['trigger_tone'] = match ($trigger) {
'editor-save', 'editor-command' => 'ok',
'module' => 'orange',
'contextmenu' => 'navy',
'switch-on' => 'warn',
default => 'info',
};
$paths = preg_split('/\s*,\s*/', trim((string)$row['paths']), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$row['paths_all'] = $paths === ['*'];
$row['paths_count'] = count($paths);
$row['paths_first'] = array_slice($paths, 0, 2);
$row['paths_rest'] = array_slice($paths, 2);
}
unset($row);
return $rows;
}
/**
* Everything the cockpit draws (ssr-cockpit.js reads it as JSON):
* availability strip, traffic buckets for 24 h / 7 days, response
* sparkline, hit rate, client errors per hour. Source is the health
* log (cron vitec:ssr-health, every 5 min, kept 14 days); the node's
* hit/miss/stale counters are cumulative since its last start, so
* traffic is the difference between two samples (a smaller value
* than before means the node restarted: then the new value counts).
*/
private function cockpitData(?array $health, ?array $lastSample): array
{
$now = time();
$tz = (int)date('Z');
$from = $now - 7 * 86400 - 4 * 3600;
$samples = [];
$purges = [];
$errors = [];
try {
$samples = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
'SELECT crdate, ok, response_ms, cached_pages, cache_mb, stat_hit, stat_miss, stat_stale, stat_bypass,'
. ' stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog WHERE crdate > ? ORDER BY crdate, uid',
[$from]
)->fetchAllAssociative();
$purges = $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->executeQuery(
'SELECT crdate, paths, success FROM tx_vitec_ssrpurgelog WHERE crdate > ? ORDER BY crdate',
[$from]
)->fetchAllAssociative();
$errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$now - 86400]
)->fetchAllAssociative();
} catch (\Throwable) {
// Tables missing on a fresh system: the cockpit shows empty charts.
}
// Traffic deltas between consecutive samples with counters.
$keys = ['hit', 'miss', 'stale', 'bypass', 'render_errors', 'backend_errors'];
$prev = null;
foreach ($samples as &$s) {
foreach ($s as $k => $v) {
$s[$k] = (int)$v;
}
$s['d'] = null;
if ($s['stat_hit'] >= 0) {
if ($prev !== null) {
$d = [];
foreach ($keys as $k) {
$c = max(0, $s['stat_' . $k]);
$p = max(0, $prev['stat_' . $k]);
$d[$k] = $c >= $p ? $c - $p : $c;
}
$s['d'] = $d;
}
$prev = $s;
}
}
unset($s);
$buckets = function (int $size, int $count) use ($now, $tz, $samples, $purges): array {
$end = intdiv($now + $tz, $size) * $size - $tz + $size;
$start = $end - $size * $count;
$out = [];
for ($i = 0; $i < $count; $i++) {
$out[] = ['t' => $start + $i * $size, 'hit' => 0, 'stale' => 0, 'miss' => 0, 'bypass' => 0,
'errors' => 0, 'msSum' => 0, 'msN' => 0, 'purges' => 0, 'purgeAll' => 0];
}
foreach ($samples as $s) {
$i = intdiv($s['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
if ($s['d'] !== null) {
$out[$i]['hit'] += $s['d']['hit'];
$out[$i]['stale'] += $s['d']['stale'];
$out[$i]['miss'] += $s['d']['miss'];
$out[$i]['bypass'] += $s['d']['bypass'];
$out[$i]['errors'] += $s['d']['render_errors'] + $s['d']['backend_errors'];
}
if ($s['ok'] === 1) {
$out[$i]['msSum'] += $s['response_ms'];
$out[$i]['msN']++;
}
}
foreach ($purges as $p) {
$i = intdiv((int)$p['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
$out[$i]['purges']++;
if (trim((string)$p['paths']) === '*') {
$out[$i]['purgeAll']++;
}
}
foreach ($out as &$b) {
$b['ms'] = $b['msN'] > 0 ? (int)round($b['msSum'] / $b['msN']) : null;
unset($b['msSum'], $b['msN']);
}
return $out;
};
// Availability strip: 96 windows of 15 minutes.
$slot = 900;
$stripEnd = intdiv($now, $slot) * $slot + $slot;
$strip = array_fill(0, 96, null);
$ok24 = 0;
$n24 = 0;
$ms24 = [];
$slow = 0;
$down = 0;
foreach ($samples as $s) {
$i = 95 - intdiv($stripEnd - 1 - $s['crdate'], $slot);
if ($i < 0) {
continue;
}
$n24++;
$state = $s['ok'] !== 1 ? 2 : ($s['response_ms'] > 400 ? 1 : 0);
if ($s['ok'] === 1) {
$ok24++;
$ms24[] = $s['response_ms'];
}
$cur = $strip[$i];
if ($cur === null || $state > $cur['s']) {
$strip[$i] = ['s' => $state, 'ms' => $s['response_ms']];
}
}
foreach ($strip as $w) {
if ($w !== null && $w['s'] === 1) {
$slow++;
}
if ($w !== null && $w['s'] === 2) {
$down++;
}
}
sort($ms24);
$pct = static function (array $sorted, float $p): ?int {
if ($sorted === []) {
return null;
}
return $sorted[(int)min(count($sorted) - 1, floor($p * (count($sorted) - 1) + 0.5))];
};
// Hit rate now (last 24 h) and the 24 h before.
$rate = static function (array $samples, int $a, int $b): ?float {
$hit = 0;
$all = 0;
foreach ($samples as $s) {
if ($s['d'] === null || $s['crdate'] <= $a || $s['crdate'] > $b) {
continue;
}
$hit += $s['d']['hit'] + $s['d']['stale'];
$all += $s['d']['hit'] + $s['d']['stale'] + $s['d']['miss'];
}
return $all > 0 ? round($hit / $all * 100, 1) : null;
};
// Response time sparkline: the last 48 good samples (4 hours).
$spark = [];
foreach (array_slice(array_values(array_filter($samples, static fn(array $s): bool => $s['ok'] === 1)), -48) as $s) {
$spark[] = [$s['crdate'], $s['response_ms']];
}
// Client errors per hour, last 24 h.
$errHours = array_fill(0, 24, 0);
$hourEnd = intdiv($now, 3600) * 3600 + 3600;
foreach ($errors as $e) {
if (ClientErrorClassifier::isTest((string)$e['message'], (string)$e['user_agent'], (string)$e['release'])) {
continue;
}
$i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600);
if ($i >= 0 && $i < 24) {
$errHours[$i]++;
}
}
$cachedPages = $health['cachedPages'] ?? ($lastSample['cached_pages'] ?? null);
$cacheMb = $health['cacheMB'] ?? ($lastSample['cache_mb'] ?? null);
return [
'now' => $now,
'online' => $health !== null,
'warming' => $health['warming'] ?? null,
'strip' => ['end' => $stripEnd, 'slot' => $slot, 'w' => $strip],
'uptimePct' => $n24 > 0 ? round($ok24 / $n24 * 100, 1) : null,
'slowWindows' => $slow,
'downWindows' => $down,
'msMedian' => $pct($ms24, 0.5),
'msP95' => $pct($ms24, 0.95),
'spark' => $spark,
'hitRate' => $rate($samples, $now - 86400, $now),
'hitRatePrev' => $rate($samples, $now - 2 * 86400, $now - 86400),
'cachedPages' => $cachedPages !== null ? (int)$cachedPages : null,
'cacheMb' => $cacheMb !== null ? (int)$cacheMb : null,
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
'errHours' => $errHours,
'range' => [
'24h' => ['size' => 3600, 'b' => $buckets(3600, 24)],
'7d' => ['size' => 14400, 'b' => $buckets(14400, 42)],
],
];
}
public function purgeAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$mode = (string)($body['mode'] ?? 'paths');
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
if ($mode === 'all') {
$paths = ['*'];
} else {
$paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
if ($paths === []) {
$this->flash('No paths given.', 'Purge', false);
} else {
$result = $this->purgeService->purge($paths, 'module:' . $user);
$this->flash(
sprintf('HTTP %d — %s', $result['httpCode'], $result['message']),
$result['success'] ? 'Purge successful' : 'Purge failed',
$result['success']
);
}
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* "Warm cache" button: asks the node to render every page of the
* sitemap in the background (0066-FE). Returns at once; progress is
* the "Cache warming" row of the status table.
*/
public function warmAction(ServerRequestInterface $request): ResponseInterface
{
$result = $this->purgeService->warm();
$this->flash(
$result['message'],
$result['success'] ? 'Cache warming' : 'Cache warming failed',
$result['success']
);
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
* the page tree context menu item (ssr-context-menu.js).
*/
public function purgePage(ServerRequestInterface $request): ResponseInterface
{
$body = (array)($request->getParsedBody() ?? []);
$pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0));
if ($pageUid <= 0) {
return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400);
}
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/';
} catch (\Throwable) {
return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']);
}
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
$result = $this->purgeService->purge([$path], 'contextmenu:' . $user);
return new JsonResponse([
'success' => $result['success'],
'path' => $path,
'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'],
]);
}
/**
* GET /__health of the SSR node. Returns [health array|null, error string].
*/
private function fetchHealth(): array
{
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
return [null, 'var/vitec-ssr-purge.env missing or incomplete.'];
}
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($response->getStatusCode() !== 200) {
return [null, 'HTTP ' . $response->getStatusCode()];
}
$health = json_decode((string)$response->getBody(), true) ?: null;
if ($health === null) {
return [null, 'invalid JSON from /__health'];
}
$health['responseMs'] = (int)round((microtime(true) - $start) * 1000);
return [$health, ''];
} catch (\Throwable $e) {
return [null, $e->getMessage()];
}
}
/**
* Ajax route vitec_ssr_health: live status for the cockpit (warm
* progress ring, cache fill, online state; ssr-cockpit.js polls it).
*/
public function healthJson(ServerRequestInterface $request): ResponseInterface
{
[$health, $error] = $this->fetchHealth();
return new JsonResponse(['health' => $health, 'error' => $error]);
}
private function rows(string $table, int $limit): array
{
try {
$qb = $this->connectionPool->getQueryBuilderForTable($table);
return $qb->select('*')->from($table)
->orderBy('uid', 'DESC')->setMaxResults($limit)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
}
}
private function flash(string $message, string $title, bool $ok): void
{
$flash = GeneralUtility::makeInstance(
FlashMessage::class,
$message,
$title,
$ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR,
true
);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash);
}
}
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Evomedien\Vitec\EventListener; namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent; use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Resource\FileRepository; use TYPO3\CMS\Core\Resource\FileRepository;
@@ -19,17 +22,23 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* news and the next events as image cards, queried live - editors touch * news and the next events as image cards, queried live - editors touch
* news and event records, never the menu. * news and event records, never the menu.
* *
* Conventions mirrored from the site set: news live on pid 11 (type 0 only, * Conventions mirrored from the site set: news are type 0 only (same filter
* same filter as the Solr indexing TS), their details render on page 21 * as the Solr indexing TS), their details render on page 21 through the
* through the NewsDetail route enhancer (/news/article/<path_segment>). * NewsDetail route enhancer (/news/article/<path_segment>).
* Events come from the vitec event table; an event links to its own * Events come from the vitec event table; an event links to its own
* eventurl when set, otherwise to the events page. * eventurl when set, otherwise to the events page.
*
* News sources (2026-11-05, ticket 0046-FE: the newer posts sit in folder
* 442, the query only read 11): the entry's "Data source folders" and
* "Data source categories" decide, editable in the menu record. No folder
* set = 11 + 442 (like the /insights/news plugin), no category = all.
*/ */
#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')] #[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')]
final class ProvideInsightsSpotlight final class ProvideInsightsSpotlight
{ {
private const NEWS_TABLE = 'tx_news_domain_model_news'; private const NEWS_TABLE = 'tx_news_domain_model_news';
private const NEWS_STORAGE_PID = 11; private const DEFAULT_NEWS_PIDS = [11, 442];
private const ENTRY_TABLE = 'tx_evomegamenujson_entry';
private const NEWS_DETAIL_PATH = '/news/article/'; private const NEWS_DETAIL_PATH = '/news/article/';
private const EVENT_TABLE = 'tx_vitec_domain_model_event'; private const EVENT_TABLE = 'tx_vitec_domain_model_event';
private const EVENTS_PAGE = '/insights/events'; private const EVENTS_PAGE = '/insights/events';
@@ -44,25 +53,83 @@ final class ProvideInsightsSpotlight
public function __invoke(BuildSpotlightEvent $event): void public function __invoke(BuildSpotlightEvent $event): void
{ {
$stage = $event->getStage(); $stage = $event->getStage();
$entry = $event->getEntry();
// group field: "11,442" (older notation "pages_11" tolerated)
$pids = array_values(array_filter(array_map(
static fn(string $value): int => (int)preg_replace('/^\D+_/', '', trim($value)),
explode(',', (string)($entry['source_pages'] ?? ''))
)));
$event->setSpotlight([ $event->setSpotlight([
'news' => $this->latestNews($stage), 'news' => $this->latestNews($stage, $pids ?: self::DEFAULT_NEWS_PIDS, $this->entryCategories((int)($entry['uid'] ?? 0))),
'events' => $this->upcomingEvents($stage), 'events' => $this->upcomingEvents($stage),
]); ]);
$this->tagPageCache();
} }
/** @return array<int,array<string,mixed>> */ /**
private function latestNews(int $stage): array * The menu sits in every cached page; tagging the page with the news and
* event tables lets the DataHandler refresh those pages as soon as an
* editor saves a news or event record (it flushes the tag `<table>`).
*/
private function tagPageCache(): void
{
try {
$collector = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('frontend.cache.collector');
if ($collector instanceof CacheDataCollectorInterface) {
$collector->addCacheTags(new CacheTag(self::NEWS_TABLE), new CacheTag(self::EVENT_TABLE));
}
} catch (\Throwable) {
// untagged, the menu only ages until the next flush
}
}
/**
* Categories picked in the entry's "Data source categories".
*
* @return int[]
*/
private function entryCategories(int $entryUid): array
{
if ($entryUid <= 0) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
return array_map('intval', $queryBuilder->select('uid_local')->from('sys_category_record_mm')
->where(
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::ENTRY_TABLE)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('source_categories')),
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($entryUid, ParameterType::INTEGER))
)
->executeQuery()->fetchFirstColumn());
}
/**
* @param int[] $pids news folders
* @param int[] $categories only news in one of these; empty = all
* @return array<int,array<string,mixed>>
*/
private function latestNews(int $stage, array $pids, array $categories): array
{ {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE); $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
try { try {
$rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment') $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
->from(self::NEWS_TABLE) ->from(self::NEWS_TABLE)
->where( ->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)), $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0')) $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
) );
->orderBy('datetime', 'DESC') if ($categories !== []) {
$mm = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
$mm->select('uid_foreign')->from('sys_category_record_mm')
->where(
$mm->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::NEWS_TABLE)),
$mm->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories')),
$mm->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
);
$queryBuilder->andWhere($queryBuilder->expr()->in('uid', $mm->getSQL()));
}
$rows = $queryBuilder->orderBy('datetime', 'DESC')
->setMaxResults(self::NEWS_COUNT) ->setMaxResults(self::NEWS_COUNT)
->executeQuery()->fetchAllAssociative(); ->executeQuery()->fetchAllAssociative();
} catch (\Throwable) { } catch (\Throwable) {
@@ -16,8 +16,8 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels, * version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
* the menu ends at the product OVERVIEW pages. * the menu ends at the product OVERVIEW pages.
* *
* categories[] (A, menu label) = sysfolders directly below /products * categories[] (A) = category pages directly below /products
* groups[] (B, menu label) = sysfolders below those * groups[] (B) = sub-category pages below those
* items[] type "page" = overview pages (/products/<slug>) * items[] type "page" = overview pages (/products/<slug>)
* type "shortcut" = shortcut pages, resolved to a target * type "shortcut" = shortcut pages, resolved to a target
* group or overview page of this tree * group or overview page of this tree
@@ -29,6 +29,21 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* pages the visitor reaches from the overview page; the menu does not show * pages the visitor reaches from the overview page; the menu does not show
* them, the count and the list serve the "N sub-products" line and search. * them, the count and the list serve the "N sub-products" line and search.
* *
* An overview page with exactly ONE product is shown as that product
* (decision Harry 2026-11-12, e.g. Avedia, EZ TV, X-Player, ChannelLink,
* ActiveSQX): `link` is the product detail page and `image` the product's
* own image (productimage, else heroimage, else the page media). `linkType`
* says which case it is (`product` | `overview`), `overviewLink` always
* keeps the overview page. The page itself stays reachable (breadcrumb,
* search, sitemap).
*
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
* category and sub-category is a clickable page. Both levels now carry
* `link` (additive; '' while a level is still a folder). Folders are still
* read, so the menu survives a half-converted tree; only groups with items
* and categories with such groups count (keeps other pages below /products,
* e.g. the 2026-10 category pages, out of the menu).
*
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The * Next to the tree: viewAll (/products) and the type-ahead endpoint. The
* boxes of the left column ("Product highlight", "Product news") are the * boxes of the left column ("Product highlight", "Product news") are the
* entry's editorial teaser cards, not data from here (decision Olli * entry's editorial teaser cards, not data from here (decision Olli
@@ -81,8 +96,8 @@ final class ProvideProductCatalog
if ($root === 0) { if ($root === 0) {
return []; return [];
} }
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]); $categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]); $groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]); $itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
$overviewUids = array_map('intval', array_column( $overviewUids = array_map('intval', array_column(
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid' array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
@@ -92,6 +107,12 @@ final class ProvideProductCatalog
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p] // positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
$groupPos = []; $groupPos = [];
$pagePos = []; $pagePos = [];
// only the products tree: groups with items, categories with such groups
$filledGroups = array_map('intval', array_column($itemRows, 'pid'));
$groupRows = array_values(array_filter($groupRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledGroups, true)));
$filledCategories = array_map('intval', array_column($groupRows, 'pid'));
$categoryRows = array_values(array_filter($categoryRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledCategories, true)));
$categories = []; $categories = [];
foreach ($categoryRows as $c => $categoryRow) { foreach ($categoryRows as $c => $categoryRow) {
$groups = []; $groups = [];
@@ -106,22 +127,27 @@ final class ProvideProductCatalog
$uid = (int)$itemRow['uid']; $uid = (int)$itemRow['uid'];
$pagePos[$uid] = [$c, $g, $p]; $pagePos[$uid] = [$c, $g, $p];
$products = $productsByPage[$uid] ?? []; $products = $productsByPage[$uid] ?? [];
// exactly one product: the tile is that product (link + image), see class comment
$single = count($products) === 1 ? $products[0] : null;
$item = [ $item = [
'type' => 'page', 'type' => 'page',
'uid' => $uid, 'uid' => $uid,
'title' => (string)$itemRow['title'], 'title' => (string)$itemRow['title'],
'link' => (string)$itemRow['slug'], 'link' => $single !== null ? $single['link'] : (string)$itemRow['slug'],
'linkType' => $single !== null ? 'product' : 'overview',
'overviewLink' => (string)$itemRow['slug'],
'productCount' => count($products), 'productCount' => count($products),
'products' => $products, 'products' => array_map(static fn(array $p): array => ['title' => $p['title'], 'link' => $p['link']], $products),
]; ];
if ($stage >= 2) { if ($stage >= 2) {
$item['image'] = $this->pageImage($uid); $item['image'] = ($single !== null && $single['uid'] > 0 ? $this->productImage($single['uid']) : null)
?? $this->pageImage($uid);
} }
$items[] = $item; $items[] = $item;
} }
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items]; $groups[] = ['title' => (string)$groupRow['title'], 'link' => $this->sectionLink($groupRow), 'items' => $items];
} }
$categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups]; $categories[] = ['title' => (string)$categoryRow['title'], 'link' => $this->sectionLink($categoryRow), 'groups' => $groups];
} }
// shortcuts: target = a group of this tree (opens that group) or an // shortcuts: target = a group of this tree (opens that group) or an
@@ -159,6 +185,12 @@ final class ProvideProductCatalog
return $categories; return $categories;
} }
/** @param array<string,mixed> $row category or sub-category page row; '' while it is a folder */
private function sectionLink(array $row): string
{
return (int)$row['doktype'] === self::DOKTYPE_PAGE ? (string)$row['slug'] : '';
}
private function productsRootUid(): int private function productsRootUid(): int
{ {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
@@ -173,7 +205,9 @@ final class ProvideProductCatalog
/** /**
* Visible child pages (frontend restrictions: hidden/deleted/start/end), * Visible child pages (frontend restrictions: hidden/deleted/start/end),
* nav_hide respected, in page tree order. * nav_hide respected, in page tree order. `title` is the menu label:
* nav_title when set (the adopted 2026-10 category pages carry the
* sitemap name there, their page title stays the SEO/H1 one).
* *
* @param int[] $parents * @param int[] $parents
* @param int[] $doktypes * @param int[] $doktypes
@@ -185,7 +219,7 @@ final class ProvideProductCatalog
return []; return [];
} }
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages') $rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug', 'doktype', 'shortcut')->from('pages')
->where( ->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
@@ -194,6 +228,12 @@ final class ProvideProductCatalog
) )
->orderBy('pid')->addOrderBy('sorting') ->orderBy('pid')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative(); ->executeQuery()->fetchAllAssociative();
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
} }
/** /**
@@ -201,7 +241,7 @@ final class ProvideProductCatalog
* product detail page (container children included), creation order. * product detail page (container children included), creation order.
* *
* @param int[] $pageUids * @param int[] $pageUids
* @return array<int,array<int,array{title:string,link:string}>> page uid => products * @return array<int,array<int,array{title:string,link:string,uid:int}>> page uid => products
*/ */
private function productsOnPages(array $pageUids): array private function productsOnPages(array $pageUids): array
{ {
@@ -217,16 +257,49 @@ final class ProvideProductCatalog
) )
->orderBy('uid') ->orderBy('uid')
->executeQuery()->fetchAllAssociative(); ->executeQuery()->fetchAllAssociative();
$uidBySlug = $rows === [] ? [] : $this->productUidsBySlug();
$byPage = []; $byPage = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$link = (string)$row['vitec_primary_cta'];
$slug = trim((string)parse_url($link, PHP_URL_PATH), '/');
$slug = substr($slug, strlen('product/'));
$byPage[(int)$row['pid']][] = [ $byPage[(int)$row['pid']][] = [
'title' => (string)$row['header'], 'title' => (string)$row['header'],
'link' => (string)$row['vitec_primary_cta'], 'link' => $link,
'uid' => $uidBySlug[$slug] ?? 0,
]; ];
} }
return $byPage; return $byPage;
} }
/** @return array<string,int> product slug (without slashes) => uid, live products only */
private function productUidsBySlug(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_vitec_domain_model_product');
$rows = $queryBuilder->select('uid', 'slug')->from('tx_vitec_domain_model_product')
->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[trim((string)$row['slug'], '/')] = (int)$row['uid'];
}
return $map;
}
/** The product's own picture (productimage, else heroimage) as 640px tile - or null. */
private function productImage(int $productUid): ?array
{
try {
foreach (['productimage', 'heroimage'] as $field) {
$references = $this->fileRepository->findByRelation('tx_vitec_domain_model_product', $field, $productUid);
if ($references !== []) {
return $this->tile($references[0]);
}
}
} catch (\Throwable) {
}
return null;
}
/** First page media (page properties > resources), 640px tile, root-relative - or null. */ /** First page media (page properties > resources), 640px tile, root-relative - or null. */
private function pageImage(int $pageUid): ?array private function pageImage(int $pageUid): ?array
{ {
@@ -235,6 +308,17 @@ final class ProvideProductCatalog
if ($references === []) { if ($references === []) {
return null; return null;
} }
return $this->tile($references[0]);
} catch (\Throwable) {
return null;
}
}
/** @return array{url:string,alternative:string,width:int,height:int}|null */
private function tile(\TYPO3\CMS\Core\Resource\FileReference $reference): ?array
{
try {
$references = [$reference];
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class); $imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']); $processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
$url = $imageService->getImageUri($processed); $url = $imageService->getImageUri($processed);
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Headless;
use Netzbewegung\NbHeadlessContentBlocks\DataProcessing\ToArray\FileReferenceToArray;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* XCLASS of the headless-content-blocks file serializer: delivers
* processed, responsive images instead of the original upload.
*
* Why (Lighthouse 2026-11-10, home page score 40 / LCP 25 s / 42 MB): the
* stock serializer emits `publicUrl` = the ORIGINAL file for every
* content-block image field (hero_bgimage, intro_image, card images …).
* Editors upload 4-13 MB JPEGs, so that is what every visitor downloads -
* while news/product images already go through the ImageService.
*
* Output per image (additive to the stock keys id/alt/title):
* - publicUrl: now the processed default variant (WebP, max. 1920 px wide,
* editor crop applied) - the frontend needs no change to benefit.
* - originalUrl: the untouched upload, should anyone need it.
* - srcset: WebP steps 480-1920 px (steps that would fall back to the
* original are skipped), as {url,width,descriptor} like elsewhere.
* - width/height: pixel size of the default variant (aspect ratio for
* CLS-free layout) - mimeType alongside.
* Non-images (video, pdf …), SVG and GIF keep the stock behaviour and only
* gain mimeType/size (GIF processing would drop animation; SVG scales).
*
* Exception-safe: any processing failure falls back to the stock output.
*/
class ProcessedFileReferenceToArray extends FileReferenceToArray
{
private const DEFAULT_WIDTH = 1920;
private const SRCSET_WIDTHS = [480, 768, 1024, 1440, 1920];
private const SKIP_EXTENSIONS = ['svg', 'gif'];
public function toArray(): array
{
$data = parent::toArray();
try {
$file = $this->fileReference;
$mimeType = (string)$file->getMimeType();
$extension = strtolower((string)$file->getExtension());
// Alt-text fallback (Seobility: 215 pages with empty alt): the
// reference title, then the file's title property. Editors can
// still override per reference.
if (trim((string)($data['alt'] ?? '')) === '') {
$fallbackAlt = trim((string)($file->getTitle() ?? ''));
if ($fallbackAlt === '') {
$fallbackAlt = trim((string)($file->getOriginalFile()->getProperty('title') ?? ''));
}
if ($fallbackAlt !== '') {
$data['alt'] = $fallbackAlt;
}
}
$data['mimeType'] = $mimeType;
$data['size'] = (int)$file->getSize();
if (!str_starts_with($mimeType, 'image/') || in_array($extension, self::SKIP_EXTENSIONS, true)) {
if (str_starts_with($mimeType, 'image/')) {
$data['width'] = (int)$file->getProperty('width');
$data['height'] = (int)$file->getProperty('height');
}
return $data;
}
$crop = null;
if ($file->hasProperty('crop') && $file->getProperty('crop')) {
$cropArea = CropVariantCollection::create((string)$file->getProperty('crop'))->getCropArea('default');
if (!$cropArea->isEmpty()) {
$crop = $cropArea->makeAbsoluteBasedOnFile($file);
}
}
$imageService = GeneralUtility::makeInstance(ImageService::class);
$instructions = static fn (int $width): array => array_filter([
'width' => $width,
'crop' => $crop,
'fileExtension' => 'webp',
], static fn ($v) => $v !== null);
$default = $imageService->applyProcessingInstructions($file, $instructions(self::DEFAULT_WIDTH));
$srcset = [];
foreach (self::SRCSET_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions($file, $instructions($width));
if ($variant->usesOriginalFile()) {
continue;
}
$srcset[] = [
'url' => $imageService->getImageUri($variant, true),
'width' => (int)$variant->getProperty('width'),
'descriptor' => (int)$variant->getProperty('width') . 'w',
];
}
$data['originalUrl'] = (string)($data['publicUrl'] ?? '');
$data['originalSize'] = (int)$file->getSize();
$data['publicUrl'] = $imageService->getImageUri($default, true);
$data['width'] = (int)$default->getProperty('width');
$data['height'] = (int)$default->getProperty('height');
$data['size'] = (int)$default->getSize();
$data['srcset'] = $srcset;
$data['mimeType'] = (string)($default->getMimeType() ?: 'image/webp');
} catch (\Throwable $e) {
// keep the stock payload
}
return $data;
}
}
@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Hooks;
use TYPO3\CMS\Core\Core\Environment;
use Evomedien\Vitec\Service\SsrPurgeService;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Auto-purge: tells the SSR node which pages to drop from its full-page
* cache after editors change content (ticket 0054-FE section 3).
*
* Mapping rules (v1, deliberately conservative):
* - pages: navigation-relevant field changed (title, nav_title, slug,
* hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page),
* otherwise just that page's path.
* - tt_content: the hosting page's path; the megamenu element -> '*'.
* - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that
* show the record (SsrPurgeTargetResolver: detail URL, explicit
* selections, list plugins, embedding records); '*' for URL/visibility
* changes, the newest news, events and anything unknown.
* - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page.
* - delete/move/undelete commands: pages, records and megamenu -> '*',
* content elements -> hosting page.
*
* Changes are queued per request and flushed ONCE in afterAllOperations,
* so bulk operations (importer!) end up as a single purge call.
* Runs only while the SSR routing flag is ON and the Site Set switch
* vitec.ssr.autoPurge is enabled.
*/
final class DataHandlerSsrPurge
{
private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
/** @var array<string,true> */
private static array $queue = [];
public function processDatamap_afterDatabaseOperations(
string $status,
string $table,
$id,
array $fieldArray,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
if (array_intersect(array_keys($fieldArray), self::NAV_FIELDS) !== []) {
self::$queue['*'] = true;
return;
}
$pageId = $this->realId($id, $dataHandler);
if ($pageId > 0) {
$this->queuePagePath($pageId);
}
return;
}
if ($table === 'tt_content') {
$recordId = $this->realId($id, $dataHandler);
if ($this->isMegamenuElement($recordId)) {
self::$queue['*'] = true;
return;
}
$pid = $fieldArray['pid'] ?? null;
if (!is_numeric($pid) || (int)$pid <= 0) {
$pid = $this->pidOfRecord($table, $recordId);
}
if ((int)$pid > 0) {
$this->queuePagePath((int)$pid);
}
return;
}
if (str_starts_with($table, self::MEGAMENU_PREFIX)) {
self::$queue['*'] = true;
return;
}
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray));
foreach ($paths as $path) {
self::$queue[$path] = true;
}
}
}
public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
{
$this->flush('editor-save');
}
public function processCmdmap_postProcess(
string $command,
string $table,
$id,
$value,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
// Delete/move/copy of a page changes menus everywhere.
self::$queue['*'] = true;
return;
}
if ($table === 'tt_content') {
if ($this->isMegamenuElement((int)$id)) {
self::$queue['*'] = true;
return;
}
$pid = $this->pidOfRecord($table, (int)$id);
if ($pid > 0) {
$this->queuePagePath($pid);
}
return;
}
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')
|| str_starts_with($table, self::MEGAMENU_PREFIX)) {
// Delete, move, copy, undelete: lists change everywhere.
self::$queue['*'] = true;
}
}
/**
* The megamenu element (CType evomegamenujson_megamenu) renders on
* every page through the site setting megamenu.contentUid.
*/
private function isMegamenuElement(int $uid): bool
{
if ($uid <= 0) {
return false;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$qb->getRestrictions()->removeAll();
$cType = $qb->select('CType')->from('tt_content')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return $cType === 'evomegamenujson_megamenu';
} catch (\Throwable) {
return false;
}
}
public function processCmdmap_afterFinish(DataHandler $dataHandler): void
{
$this->flush('editor-command');
}
private function flush(string $trigger): void
{
if (self::$queue === []) {
return;
}
$paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
self::$queue = [];
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
}
private function isActive(): bool
{
if (!file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag')) {
return false;
}
try {
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
if ($site->getSettings()->get('vitec.ssr.autoPurge', true)) {
return true;
}
}
} catch (\Throwable) {
return false;
}
return false;
}
private function queuePagePath(int $pageId): void
{
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
$uri = $site->getRouter()->generateUri($pageId);
$path = $uri->getPath() ?: '/';
self::$queue[$path] = true;
} catch (\Throwable) {
// Page outside a site (folders etc.) - nothing cached for it.
}
}
private function realId($id, DataHandler $dataHandler): int
{
if (is_numeric($id)) {
return (int)$id;
}
return (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
}
private function pidOfRecord(string $table, int $uid): int
{
if ($uid <= 0) {
return 0;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$qb->getRestrictions()->removeAll();
$pid = $qb->select('pid')->from($table)
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return (int)$pid;
} catch (\Throwable) {
return 0;
}
}
}
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
/**
* Client error endpoint: POST /api/vitec/client-error
*
* The React frontend reports browser-side failures here (window.onerror,
* unhandledrejection, chunk reloads after a deploy, element error
* boundaries) - contract 0072-FE section 4, accepted in 0077-BE:
*
* {url, message, stack, release, userAgent} -> 204 No Content
*
* Guards, all answered with a 4xx and no body:
* 405 method other than POST
* 413 body larger than 8 KB (Content-Length or actual bytes)
* 400 not a JSON object, or "message" missing/empty
* 429 more than 30 reports per minute from one IP (sliding window
* via TYPO3's rate limiter, cache "ratelimiter")
*
* Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
* last octet zeroed, IPv6 cut to /48); the full IP is only the key of
* the rate limiter and is never stored. No session, no cookies - a
* navigator.sendBeacon() call must get through unchanged. Rows older
* than 30 days are removed by vitec:client-error-cleanup.
*/
final class ClientErrorMiddleware implements MiddlewareInterface
{
private const PATH = '/api/vitec/client-error';
private const MAX_BODY_BYTES = 8192;
private const LIMIT_PER_MINUTE = 30;
private const TABLE = 'tx_vitec_clienterrorlog';
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly RateLimiterFactory $rateLimiterFactory,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = rtrim($request->getUri()->getPath(), '/');
if ($path !== self::PATH) {
return $handler->handle($request);
}
$method = strtoupper($request->getMethod());
if ($method === 'OPTIONS') {
return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
}
if ($method !== 'POST') {
return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
}
$declared = (int)$request->getHeaderLine('Content-Length');
if ($declared > self::MAX_BODY_BYTES) {
return $this->empty(413);
}
// Rate limit first, so a flood of oversized or broken bodies is
// cheap to refuse as well.
$ip = '';
try {
$normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
$ip = (string)$normalized->getRemoteAddress();
$limiter = $this->rateLimiterFactory->createLimiter([
'id' => 'vitec-client-error',
'policy' => 'sliding_window',
'limit' => self::LIMIT_PER_MINUTE,
'interval' => '1 minute',
], $ip);
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
$retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
return $this->empty(429)->withHeader('Retry-After', (string)$retry);
}
} catch (\Throwable) {
// A broken limiter must not take the endpoint down; the
// body limit and the 30-day cleanup still bound the damage.
}
$body = $request->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
$raw = $body->read(self::MAX_BODY_BYTES + 1);
if (strlen($raw) > self::MAX_BODY_BYTES) {
return $this->empty(413);
}
try {
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return $this->empty(400);
}
if (!\is_array($data) || array_is_list($data)) {
return $this->empty(400);
}
$message = trim($this->str($data['message'] ?? ''));
if ($message === '') {
return $this->empty(400);
}
$row = [
'pid' => 0,
'crdate' => time(),
'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
'message' => $this->cut($message, 2000),
'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
'release' => $this->cut($this->str($data['release'] ?? ''), 100),
'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
'ip' => self::anonymizeIp($ip),
];
try {
$this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
} catch (\Throwable) {
// Table missing (extension:setup not run yet) or DB hiccup:
// the client gets its 204 anyway - a reporting endpoint must
// never turn into a second error in the browser console.
}
return $this->empty(204);
}
/**
* IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
* kept (2001:db8:1::). Anything else: empty.
*/
public static function anonymizeIp(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$parts = explode('.', $ip);
$parts[3] = '0';
return implode('.', $parts);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$packed = inet_pton($ip);
if ($packed === false) {
return '';
}
$hex = bin2hex($packed);
$groups = [];
for ($i = 0; $i < 3; $i++) {
$groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
}
return implode(':', $groups) . '::';
}
return '';
}
private function str(mixed $value): string
{
if (\is_string($value)) {
return $value;
}
if (\is_int($value) || \is_float($value) || \is_bool($value)) {
return (string)$value;
}
return '';
}
private function cut(string $value, int $max): string
{
$value = str_replace("\0", '', $value);
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
}
private function empty(int $status): ResponseInterface
{
return (new Response())
->withStatus($status)
->withHeader('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\Response;
/**
* Normalizes client caching for the headless JSON API (/_json):
* a single "Cache-Control: no-cache" plus a content ETag, answering
* If-None-Match with an empty 304 so browsers revalidate instead of
* re-downloading (tickets 0011-FE / 0055-BE).
*
* "Expires: 0" is set on purpose: mod_expires leaves responses alone
* that already carry an Expires header, otherwise it would re-add
* max-age/Expires on top of ours (public/.htaccess ExpiresByType).
*
* Logged-in visitors (fe_typo_user cookie) keep TYPO3's own headers:
* their responses may be personalized and must not be eligible for
* shared revalidation.
*/
final class JsonCacheHeaders implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$path = $request->getUri()->getPath();
if (
!\in_array($request->getMethod(), ['GET', 'HEAD'], true)
|| $response->getStatusCode() !== 200
|| ($path !== '/_json' && !str_starts_with($path, '/_json/'))
|| isset($request->getCookieParams()['fe_typo_user'])
) {
return $response;
}
$etag = '"' . md5((string)$response->getBody()) . '"';
// mod_deflate suffixes the ETag it compressed ("<md5>-gzip");
// browsers send that form back, so accept it too (0054-FE 5.3).
$ifNoneMatch = str_replace('-gzip"', '"', trim($request->getHeaderLine('If-None-Match')));
if ($ifNoneMatch !== '' && str_contains($ifNoneMatch, $etag)) {
return (new Response())
->withStatus(304)
->withHeader('ETag', $etag)
->withHeader('Cache-Control', 'no-cache')
->withHeader('Expires', '0');
}
return $response
->withHeader('Cache-Control', 'no-cache')
->withoutHeader('Pragma')
->withHeader('Expires', '0')
->withHeader('ETag', $etag);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Service\SsrFlagService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Self-healing sync of the SSR routing flag (see SsrFlagService).
*
* Registered for BOTH stacks: backend, so flipping the Site Set switch
* takes effect with the very next backend request (the module reload
* right after saving), and frontend, so /_json traffic repairs the flag
* even if nobody is logged into the backend. When flag and setting
* agree - the normal case - this costs a single file_exists() per
* request.
*/
final class SyncSsrFlag implements MiddlewareInterface
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->ssrFlagService->syncQuietly();
return $handler->handle($request);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
/**
* Splits editor-pasted schema.org JSON blocks out of RTE HTML.
*
* The SEO-maintained news articles carry a raw FAQPage JSON object in a
* trailing <p>…</p> of their bodytext (found 2026-11-10: 17 records). Left
* in place, the frontend renders the JSON as visible text while Google
* never sees it as structured data. This helper removes such paragraphs
* from the HTML and returns the decoded nodes, ready to join the page's
* JSON-LD @graph (their own @context is dropped - the graph carries one).
*
* Fail-safe: a paragraph that merely looks like JSON but does not decode
* to a schema.org node stays in the HTML untouched.
*/
final class EmbeddedJsonLd
{
/**
* @return array{html: string, nodes: list<array<string,mixed>>}
*/
public static function extract(string $html): array
{
if ($html === '' || !str_contains($html, 'schema.org')) {
return ['html' => $html, 'nodes' => []];
}
$nodes = [];
$clean = preg_replace_callback(
'#<p[^>]*>\s*(\{.+?\})\s*</p>#s',
static function (array $match) use (&$nodes): string {
$candidate = $match[1];
if (!str_contains($candidate, 'schema.org')) {
return $match[0];
}
$decoded = json_decode($candidate, true);
if (!is_array($decoded)) {
// RTE processing may have entity-encoded the quotes.
$decoded = json_decode(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5), true);
}
if (!is_array($decoded) || empty($decoded['@type'])) {
return $match[0];
}
unset($decoded['@context']);
$nodes[] = $decoded;
return '';
},
$html
) ?? $html;
return ['html' => trim($clean), 'nodes' => $nodes];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider;
/**
* Success-story sitemap entries with their PUBLIC URLs.
*
* The route enhancer generates /success-stories/story/<slug>, but the
* public canonical URL is /success-stories/<slug> (SuccessStoryPathRewrite
* middleware, old-site SEO URLs). The sitemap must list the canonical
* form, so the generated loc is rewritten accordingly.
*/
final class StoriesXmlSitemapDataProvider extends RecordsXmlSitemapDataProvider
{
protected function defineUrl(array $data): array
{
$data = parent::defineUrl($data);
if (isset($data['loc']) && is_string($data['loc'])) {
$data['loc'] = str_replace('/success-stories/story/', '/success-stories/', $data['loc']);
}
return $data;
}
}
+457 -9
View File
@@ -4,20 +4,34 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Seo; namespace Evomedien\Vitec\Seo;
use Doctrine\DBAL\ParameterType;
use FriendsOfTYPO3\Headless\Seo\MetaHandler; use FriendsOfTYPO3\Headless\Seo\MetaHandler;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Mirrors the real page <title> into `meta.title` of the page JSON. * Completes the page JSON's `seo` block for the headless frontend.
* *
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page * On top of the stock headless MetaHandler this adds (SEO audit 2026-11-09):
* content, so on product/usecase detail pages it still shows the generic *
* page title ("Product") - the record's seotitle only reaches the * 1. Record-aware metadata: news, product and success-story detail pages
* PageTitleProvider while the content renders. Headless fixes `seo.title` * take title, meta description and og:image from the routed record -
* afterwards through this handler; we extend it so `meta.title` gets the * the shell page itself carries none of that (news details rendered
* same final value. Registered via the MetaHandlerInterface alias in * as <title>article</title> before this).
* Services.yaml, which covers both the cacheable listener and the * 2. A uniform "| VITEC" title suffix wherever the brand is missing.
* USER_INT middleware path. * 3. Fallback meta description from the page record (description/abstract).
* 4. og:title / og:description / og:image plus the twitter:* counterparts
* whenever they are missing; twitter:card upgrades to
* summary_large_image once an image exists.
* 5. Canonical rewrite for success stories: the public URL is
* /success-stories/<slug>, not the internal /success-stories/story/<slug>
* (see SuccessStoryPathRewrite middleware).
* 6. Mirrors the final page <title> into `meta.title` (pre-existing: the
* TypoScript lib.meta renders before the record providers run).
*
* Exception-safe: any failure keeps the stock result untouched.
*/ */
class VitecMetaHandler extends MetaHandler class VitecMetaHandler extends MetaHandler
{ {
@@ -25,6 +39,12 @@ class VitecMetaHandler extends MetaHandler
{ {
$content = parent::process($request, $content); $content = parent::process($request, $content);
try {
$content = $this->enrich($request, $content);
} catch (\Throwable $e) {
// keep stock output on any failure
}
$title = trim((string)($content['seo']['title'] ?? '')); $title = trim((string)($content['seo']['title'] ?? ''));
if ($title !== '' && is_array($content['meta'] ?? null)) { if ($title !== '' && is_array($content['meta'] ?? null)) {
$content['meta']['title'] = $title; $content['meta']['title'] = $title;
@@ -32,4 +52,432 @@ class VitecMetaHandler extends MetaHandler
return $content; return $content;
} }
/**
* @param array<string,mixed> $content
* @return array<string,mixed>
*/
private function enrich(ServerRequestInterface $request, array $content): array
{
$base = $this->resolveBase($request);
$args = $this->routeArguments($request);
$pageInfo = $request->getAttribute('frontend.page.information');
$page = $pageInfo !== null ? $pageInfo->getPageRecord() : [];
$title = trim((string)($content['seo']['title'] ?? ''));
$description = '';
$image = '';
$canonicalPath = '';
// --- record context -------------------------------------------------
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($newsUid > 0) {
$news = $this->fetchRow('tx_news_domain_model_news', ['uid' => $newsUid]);
if ($news !== null) {
$recordTitle = trim((string)($news['alternative_title'] ?? ''));
if ($recordTitle === '') {
$recordTitle = trim((string)($news['title'] ?? ''));
}
if ($recordTitle !== '') {
$title = $recordTitle;
}
$description = $this->plainText((string)($news['teaser'] ?? ''));
if ($description === '') {
$description = $this->plainText(
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
);
}
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
// Same text as a success story: the story is the original.
$storyUid = (int)($news['vitec_canonical_usecase'] ?? 0);
if ($storyUid > 0) {
$story = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $storyUid]);
$storySlug = trim((string)($story['slug'] ?? ''));
if ($storySlug !== '') {
$canonicalPath = '/success-stories/' . $storySlug;
}
}
}
} elseif ($productArg !== '') {
$product = is_numeric($productArg)
? $this->fetchRow('tx_vitec_domain_model_product', ['uid' => (int)$productArg])
: $this->fetchRow('tx_vitec_domain_model_product', ['slug' => $productArg]);
if ($product !== null) {
$description = trim((string)($product['seometa'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($product['teaser'] ?? ''));
}
$productUid = (int)$product['uid'];
$image = $this->firstImage('tx_vitec_domain_model_product', 'ogimage', $productUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_product', 'productimage', $productUid);
}
}
} elseif ($usecaseUid > 0) {
$usecase = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $usecaseUid]);
if ($usecase !== null) {
$recordTitle = trim((string)($usecase['seo_title'] ?? ''));
if ($recordTitle !== '') {
$title = $recordTitle;
}
// "Betfred HQ | VITEC" is too short to say what the page is.
$storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title));
if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) {
$title = $storyCore . ' | Success Story';
}
$description = trim((string)($usecase['seo_description'] ?? ''));
if ($description === '') {
$description = trim((string)($usecase['og_description'] ?? ''));
}
if ($description === '') {
$description = $this->plainText((string)($usecase['teaser'] ?? ''));
}
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'og_image', $usecaseUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'card_image', $usecaseUid);
}
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'hero_bgimage', $usecaseUid);
}
}
}
// --- fallbacks from the page record ----------------------------------
if ($description === '') {
$description = trim((string)($page['description'] ?? ''));
if ($description === '') {
$description = trim((string)($page['abstract'] ?? ''));
}
}
if ($image === '' && !empty($page['uid'])) {
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
}
// --- short page titles get their section as context -------------------
// "Aligo | VITEC" (Seobility: 456 titles "zu kurz") becomes
// "Aligo | AV over IP & KVM | VITEC"; only for plain pages without an
// editor-set SEO title, never for record detail pages.
// An SEO title that merely repeats the page title (the import copied
// title -> seo_title) counts as "not maintained".
$seoTitle = trim((string)($page['seo_title'] ?? ''));
$pageTitle = trim((string)($page['title'] ?? ''));
// "Aligo | VITEC" as seo_title is the import pattern, not editor work.
$seoCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $seoTitle));
$seoTitleMaintained = $seoCore !== '' && mb_strtolower($seoCore) !== mb_strtolower($pageTitle);
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
&& $pageTitle !== '' && mb_strlen($pageTitle) < 30
&& !$seoTitleMaintained
) {
$parentTitle = $this->parentTitle($pageInfo);
if ($parentTitle !== '' && mb_stripos($pageTitle, $parentTitle) === false) {
$title = $pageTitle . ' | ' . $parentTitle;
}
}
// --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC';
}
if ($title !== '') {
$content['seo']['title'] = $title;
}
// --- compose meta list ------------------------------------------------
$meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : [];
// ~155 characters is what search engines show (Seobility: 527 "zu lang").
$description = $this->crop($description, 155);
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
$meta[] = ['name' => 'description', 'content' => $description];
}
// Editorial descriptions already in the list get the same length cap.
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) {
$meta[$index]['content'] = $this->crop(trim($entry['content']), 155);
}
}
$finalDescription = $this->metaContent($meta, 'name', 'description');
if ($finalDescription === '') {
$finalDescription = $description;
}
if ($title !== '' && !$this->hasMeta($meta, 'property', 'og:title')) {
$meta[] = ['property' => 'og:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'property', 'og:description')) {
$meta[] = ['property' => 'og:description', 'content' => $finalDescription];
}
if ($absImage !== '' && !$this->hasMeta($meta, 'property', 'og:image')) {
$meta[] = ['property' => 'og:image', 'content' => $absImage];
}
if ($title !== '' && !$this->hasMeta($meta, 'name', 'twitter:title')) {
$meta[] = ['name' => 'twitter:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'name', 'twitter:description')) {
$meta[] = ['name' => 'twitter:description', 'content' => $finalDescription];
}
$finalImage = $this->metaContent($meta, 'property', 'og:image');
if ($finalImage !== '' && !$this->hasMeta($meta, 'name', 'twitter:image')) {
$meta[] = ['name' => 'twitter:image', 'content' => $finalImage];
}
if ($finalImage !== '') {
$hasCard = false;
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'twitter:card') {
$meta[$index]['content'] = 'summary_large_image';
$hasCard = true;
}
}
if (!$hasCard) {
$meta[] = ['name' => 'twitter:card', 'content' => 'summary_large_image'];
}
}
$content['seo']['meta'] = $meta;
// --- success-story canonical: public short URL ------------------------
if (is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (isset($link['href']) && is_string($link['href'])) {
$content['seo']['link'][$index]['href'] =
str_replace('/success-stories/story/', '/success-stories/', $link['href']);
}
}
}
// --- news article whose canonical is a success story --------------------
if ($canonicalPath !== '') {
$href = $this->absolute($canonicalPath, $base);
$links = is_array($content['seo']['link'] ?? null) ? $content['seo']['link'] : [];
$replaced = false;
foreach ($links as $index => $link) {
if (($link['rel'] ?? '') === 'canonical') {
$links[$index]['href'] = $href;
$replaced = true;
}
}
if (!$replaced) {
$links[] = ['href' => $href, 'rel' => 'canonical'];
}
$content['seo']['link'] = $links;
}
// --- paginated / filtered list pages: self-referencing canonical --------
// /insights/news?page=2 pointed at page 1, which marks every further
// page as a duplicate and weakens the links to its articles (0100-FE).
if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) {
$query = $request->getQueryParams();
$keep = [];
foreach (['category', 'page'] as $key) {
$value = $query[$key] ?? null;
if (!is_scalar($value) || trim((string)$value) === '') {
continue;
}
if ($key === 'page' && (int)$value <= 1) {
continue;
}
$keep[$key] = trim((string)$value);
}
if ($keep !== [] && is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) {
$content['seo']['link'][$index]['href'] =
strtok($link['href'], '?') . '?' . http_build_query($keep);
}
}
}
// Self-canonical pages need their own title/description, otherwise
// they count as duplicates of page 1 ("VITEC News and Articles" x3).
if ($keep !== []) {
$suffix = [];
if (isset($keep['category'])) {
$suffix[] = ucfirst(str_replace('-', ' ', $keep['category']));
}
if (isset($keep['page'])) {
$suffix[] = 'Page ' . (int)$keep['page'];
}
$suffixText = ' – ' . implode(' – ', $suffix);
$currentTitle = trim((string)($content['seo']['title'] ?? ''));
if ($currentTitle !== '') {
$content['seo']['title'] = preg_match('/^(.*?)(\s*\|\s*VITEC)$/i', $currentTitle, $m) === 1
? $m[1] . $suffixText . $m[2]
: $currentTitle . $suffixText;
}
foreach ($content['seo']['meta'] as $index => $entry) {
if (in_array($entry['name'] ?? '', ['description', 'twitter:description'], true)
|| ($entry['property'] ?? '') === 'og:description'
) {
$content['seo']['meta'][$index]['content'] = rtrim((string)$entry['content'], '. ') . $suffixText . '.';
}
}
}
}
return $content;
}
/**
* Navigation title of the parent page, or '' when the parent is the site
* root, a technical container (user_nav, folders) or missing.
*/
private function parentTitle($pageInfo): string
{
if ($pageInfo === null) {
return '';
}
// Rootline keys are the depth (0 = root), but the array is krsort()ed
// (current page first). Walk up from the parent, skipping structural
// levels (folders, shortcuts, spacers, technical names) until a page
// with a presentable name turns up; the site root never counts.
$rootLine = $pageInfo->getRootLine();
ksort($rootLine);
$byDepth = array_values($rootLine);
for ($i = count($byDepth) - 2; $i >= 1; $i--) {
$candidate = $byDepth[$i];
if (!is_array($candidate)) {
continue;
}
if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
continue;
}
$name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? ''));
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
continue;
}
return $name;
}
return '';
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments(ServerRequestInterface $request): array
{
$routing = $request->getAttribute('routing');
$args = $routing instanceof PageArguments ? $routing->getArguments() : [];
// Query-parameter fallback (uncached plugin context).
return array_replace_recursive($request->getQueryParams(), $args);
}
/**
* @param array<string,int|string> $where equality constraints
* @return array<string,mixed>|null
*/
private function fetchRow(string $table, array $where): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$constraints = [
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
];
foreach ($where as $field => $value) {
$constraints[] = is_int($value)
? $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter((string)$value, ParameterType::STRING));
}
$row = $qb->select('*')->from($table)->where(...$constraints)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
}
/**
* Root-relative URL of the first FAL image attached to a record field.
*/
private function firstImage(string $table, string $field, int $uid): string
{
if ($uid <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
return $identifier !== '' ? '/fileadmin' . $identifier : '';
}
/**
* @param list<array<string,string>> $meta
*/
private function hasMeta(array $meta, string $attribute, string $key): bool
{
return $this->metaContent($meta, $attribute, $key) !== '';
}
/**
* @param list<array<string,string>> $meta
*/
private function metaContent(array $meta, string $attribute, string $key): string
{
foreach ($meta as $entry) {
if (strtolower((string)($entry[$attribute] ?? '')) === $key) {
return trim((string)($entry['content'] ?? ''));
}
}
return '';
}
private function resolveBase(ServerRequestInterface $request): string
{
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
return $url;
}
return $base . '/' . ltrim($url, '/');
}
/**
* Collapse RTE HTML to a single plain-text line.
*/
private function plainText(string $value): string
{
$value = strip_tags($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* Crop to $max characters at a word boundary (ellipsis appended).
*/
private function crop(string $value, int $max): string
{
if ($value === '' || mb_strlen($value) <= $max) {
return $value;
}
$cut = mb_substr($value, 0, $max);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > (int)($max * 0.6)) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " \t.,;:") . '...';
}
} }
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Tells test reports and bot reports in tx_vitec_clienterrorlog apart
* from real visitor errors (ticket 0114-FE: the cockpit tile must not
* count smoke tests). One rule set for the module and the dashboard.
*
* Test: sent with curl, a user agent that says "smoke" or "client-error
* test", a message starting with "test", or the dummy release "t" of the
* endpoint smoke test. Bot: a crawler user agent (Googlebot & Co.); bot
* reports stay real errors, they only get a label.
*/
final class ClientErrorClassifier
{
public static function isTest(string $message, string $userAgent, string $release = ''): bool
{
if ($release === 't') {
return true;
}
if (preg_match('#curl/|smoke|client-error test#i', $userAgent) === 1) {
return true;
}
return preg_match('#^\s*test\b#i', $message) === 1;
}
public static function isBot(string $userAgent): bool
{
return preg_match('#bot\b|crawler|spider|slurp|lighthouse|headlesschrome#i', $userAgent) === 1;
}
public static function isChunkReload(string $message): bool
{
return str_contains(strtolower($message), 'chunk reload');
}
}
@@ -0,0 +1,676 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Page content audit for the market and solution trees (sibling of
* ProductCheckService, decision Harry 2026-11-13): which market / sub-market /
* solution-group / sub-solution PAGE carries which content, and the full
* content of one page for the detail pane.
*
* Unlike products these are pages built from content elements, so the audit
* looks at the elements in page order (hero, intro, cards, testimonials,
* products, insights, logos, CTA banner), at the page properties (meta
* description, OG image, page media) and at the market / solution record that
* points to the page via detail_page.
*
* Scopes: 'markets' = children of /markets (11 markets, 24 sub-markets)
* 'solutions' = children of /solutions (8 groups, 39 sub-solutions)
*
* Columns carry an `applies` list - a column a page type does not have (e.g.
* logos on a sub-market, insights on a main market) is delivered as
* {ok: null, count: 0} and counted neither as filled nor as applicable.
*
* Placeholders (Harry 2026-11-13): files below fileadmin/placeholders/ (or
* named "...placeholder...") are grey dummies that must not pass as finished.
* Image cells carry `placeholder` = number of such files when there is at
* least one; summary.filled counts only cells without placeholders, and
* summary.placeholder counts the cells that have one. Detail images carry
* `placeholder: true|false`. `ok` itself is unchanged (something is attached).
*
* Read fresh on every call, nothing cached (USER_INT in the renderer).
*/
final class PageCheckService
{
/** SQL condition on sys_file f that marks a placeholder file. */
public const PLACEHOLDER_SQL = "(f.identifier LIKE '/placeholders/%' OR LOWER(f.name) LIKE '%placeholder%')";
public static function isPlaceholder(string $identifier, string $name): bool
{
return str_starts_with($identifier, '/placeholders/') || str_contains(strtolower($name), 'placeholder');
}
public const SCOPES = [
'markets' => [
'label' => 'Markets',
'rootSlug' => '/markets',
'types' => ['market', 'submarket'],
'recordTable' => 'tx_vitec_domain_model_market',
],
'solutions' => [
'label' => 'Solutions',
'rootSlug' => '/solutions',
'types' => ['solution', 'subsolution'],
'recordTable' => 'tx_vitec_domain_model_solution',
],
];
private const ALL = ['market', 'submarket', 'solution', 'subsolution'];
/**
* Columns in display order. kind as in the product check (images / video /
* text / features / list), applies = page types that have the column.
*/
private const FIELDS = [
['key' => 'hero', 'label' => 'Hero image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'subline', 'label' => 'Hero subline', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'lead', 'label' => 'Lead', 'kind' => 'text', 'applies' => ['solution', 'subsolution']],
['key' => 'intro', 'label' => 'Intro text', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'introImage', 'label' => 'Intro image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'introCta', 'label' => 'Intro CTA', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'cards', 'label' => 'Cards', 'kind' => 'features', 'applies' => self::ALL],
['key' => 'stories', 'label' => 'Testimonials', 'kind' => 'list', 'applies' => self::ALL],
['key' => 'products', 'label' => 'Products', 'kind' => 'list', 'applies' => ['submarket', 'subsolution']],
['key' => 'insights', 'label' => 'Insights text', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'insightsCover', 'label' => 'Insights cover', 'kind' => 'images', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'insightsCta', 'label' => 'Insights CTA', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'logos', 'label' => 'Logos', 'kind' => 'list', 'applies' => ['market']],
['key' => 'cta', 'label' => 'CTA banner', 'kind' => 'text', 'applies' => ['market', 'solution']],
['key' => 'seo', 'label' => 'Meta description', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'og', 'label' => 'OG image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'media', 'label' => 'Page media', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'record', 'label' => 'Record', 'kind' => 'list', 'applies' => self::ALL],
];
private const HERO_COLPOS = [1, 110];
private const CONTAINER_PREFIXES = ['vitec_cols_', 'vitec_container', 'vitec_cards_carousel'];
/** Text fields per element type for the detail pane: column => label. */
private const ELEMENT_TEXTS = [
'vitec_markethero' => ['vitec_subline' => 'Subline', 'vitec_cta_label' => 'CTA label', 'vitec_video_title' => 'Video title'],
'vitec_herosection' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_cta_label' => 'CTA label'],
'vitec_introparagraph' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_button_text' => 'Button'],
'vitec_marketintro' => ['vitec_intro_headline' => 'Headline', 'vitec_intro_text' => 'Text', 'vitec_learnmore_label' => 'Button'],
'vitec_card' => ['vitec_eyebrow' => 'Eyebrow', 'header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_back_headline' => 'Back headline', 'vitec_back_text' => 'Back text', 'vitec_primary_cta_label' => 'Button'],
'vitec_usecaselist' => ['header' => 'Header'],
'vitec_customerlogos' => ['header' => 'Header'],
'vitec_marketinsights' => ['vitec_text1' => 'Text 1', 'vitec_text2' => 'Text 2', 'vitec_cta_label' => 'CTA label', 'vitec_video_person' => 'Video person', 'vitec_video_role' => 'Video role', 'vitec_video_title' => 'Video title'],
'vitec_ctabanner' => ['header' => 'Header', 'bodytext' => 'Text', 'vitec_primary_cta_label' => 'Primary button', 'vitec_secondary_cta_label' => 'Secondary button'],
'vitec_productfeature' => ['header' => 'Header'],
'vitec_productlist' => ['header' => 'Header'],
];
/** Link fields per element type: column => label. */
private const ELEMENT_LINKS = [
'vitec_markethero' => ['vitec_cta_link' => 'CTA link', 'vitec_video_url' => 'Video URL'],
'vitec_herosection' => ['vitec_cta' => 'CTA link'],
'vitec_introparagraph' => ['vitec_button_link' => 'Button link'],
'vitec_marketintro' => ['vitec_learnmore_target' => 'Button target'],
'vitec_card' => ['vitec_primary_cta' => 'Button link', 'vitec_card_link' => 'Card link'],
'vitec_marketinsights' => ['vitec_cta_target' => 'CTA target', 'vitec_video_url' => 'Video URL'],
'vitec_ctabanner' => ['vitec_primary_cta' => 'Primary link', 'vitec_secondary_cta' => 'Secondary link'],
];
private const ELEMENT_LABELS = [
'vitec_markethero' => 'Hero',
'vitec_herosection' => 'Hero section',
'vitec_introparagraph' => 'Intro paragraph',
'vitec_marketintro' => 'Intro (text | image)',
'vitec_card' => 'Card',
'vitec_usecaselist' => 'Testimonials',
'vitec_customerlogos' => 'Customer logos',
'vitec_marketinsights' => 'Insights',
'vitec_ctabanner' => 'CTA banner',
'vitec_productfeature' => 'Featured product',
'vitec_productlist' => 'Product list',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
/** @return array<string, mixed> */
public function overview(string $scope): array
{
$cfg = self::SCOPES[$scope] ?? throw new \InvalidArgumentException('Unknown scope ' . $scope);
$pages = $this->pages($cfg);
$pageUids = array_map(static fn(array $p): int => (int)$p['uid'], $pages);
$elementsByPage = $this->elementsByPage($pageUids);
$allElementUids = [];
foreach ($elementsByPage as $els) {
foreach ($els as $el) {
$allElementUids[] = (int)$el['uid'];
}
}
$elementFiles = $this->referenceCounts('tt_content', $allElementUids);
$elementPlaceholders = $this->referenceCounts('tt_content', $allElementUids, true);
$pageFiles = $this->referenceCounts('pages', $pageUids);
$pagePlaceholders = $this->referenceCounts('pages', $pageUids, true);
$records = $this->records($cfg['recordTable'], $pageUids);
$recordUids = array_map(static fn(array $r): int => (int)$r['uid'], $records);
$recordFiles = $this->referenceCounts($cfg['recordTable'], $recordUids);
$recordPlaceholders = $this->referenceCounts($cfg['recordTable'], $recordUids, true);
// Empty selection = what the testimonial band shows: quoted stories,
// at most twelve (UsecaseListJsonRenderer::testimonialRows).
$allStories = min(12, $this->countAll('tx_vitec_domain_model_usecase', "deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND TRIM(COALESCE(quote, '')) <> ''"));
$allCustomers = $this->countAll('tx_vitec_domain_model_customer', 'deleted = 0 AND hidden = 0');
$keys = array_column(self::FIELDS, 'key');
$filled = array_fill_keys($keys, 0);
$placeholder = array_fill_keys($keys, 0);
$applicable = array_fill_keys($keys, 0);
$byType = [];
$rows = [];
foreach ($pages as $p) {
$uid = (int)$p['uid'];
$type = (string)$p['_type'];
$byType[$type] = ($byType[$type] ?? 0) + 1;
$els = $elementsByPage[$uid] ?? [];
$visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0));
$hiddenCount = count($els) - count($visible);
$record = $records[$uid] ?? null;
$recordUid = $record ? (int)$record['uid'] : 0;
$raw = $this->pageChecks(
$type,
$visible,
['n' => $elementFiles, 'ph' => $elementPlaceholders],
['n' => $pageFiles[$uid] ?? [], 'ph' => $pagePlaceholders[$uid] ?? []],
$p,
$record,
['n' => $recordFiles[$recordUid] ?? [], 'ph' => $recordPlaceholders[$recordUid] ?? []],
$allStories,
$allCustomers
);
$checks = [];
foreach (self::FIELDS as $field) {
$key = $field['key'];
if (!in_array($type, $field['applies'], true)) {
$checks[$key] = ['ok' => null, 'count' => 0];
continue;
}
$checks[$key] = $raw[$key];
$applicable[$key]++;
if (($raw[$key]['placeholder'] ?? 0) > 0) {
$placeholder[$key]++;
} elseif ($raw[$key]['ok'] === true) {
$filled[$key]++;
}
}
$rows[] = $this->base($p, $type, $record, $hiddenCount) + ['checks' => $checks];
}
return [
'scope' => $scope,
'scopeLabel' => $cfg['label'],
'generatedAt' => date(\DATE_ATOM),
'fields' => self::FIELDS,
'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'placeholder' => $placeholder, 'applicable' => $applicable],
'pages' => $rows,
];
}
/** @return array<string, mixed>|null null when the page is not part of any scope */
public function detail(int $uid): ?array
{
foreach (self::SCOPES as $scope => $cfg) {
foreach ($this->pages($cfg) as $p) {
if ((int)$p['uid'] !== $uid) {
continue;
}
$type = (string)$p['_type'];
$records = $this->records($cfg['recordTable'], [$uid]);
$record = $records[$uid] ?? null;
$els = $this->elementsByPage([$uid])[$uid] ?? [];
$hiddenCount = count(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 1));
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$elements = [];
foreach ($els as $el) {
$cType = (string)$el['CType'];
$texts = [];
foreach (self::ELEMENT_TEXTS[$cType] ?? ['header' => 'Header', 'bodytext' => 'Text'] as $column => $label) {
$value = (string)($el[$column] ?? '');
$texts[] = ['key' => $column, 'label' => $label, 'ok' => $this->filled($value), 'html' => $value];
}
$links = [];
foreach (self::ELEMENT_LINKS[$cType] ?? [] as $column => $label) {
$value = trim((string)($el[$column] ?? ''));
if ($value !== '') {
$links[] = ['key' => $column, 'label' => $label, 'target' => $value];
}
}
$settings = [];
if (trim((string)($el['pi_flexform'] ?? '')) !== '') {
$settings = $flexFormService->convertFlexFormContentToArray((string)$el['pi_flexform'])['settings'] ?? [];
$settings = array_filter($settings, static fn($v): bool => is_scalar($v) && (string)$v !== '' && (string)$v !== '0');
$settings = array_map(static fn($v): string => (string)$v, $settings);
}
$elements[] = [
'uid' => (int)$el['uid'],
'type' => $cType,
'label' => self::ELEMENT_LABELS[$cType] ?? $cType,
'colPos' => (int)$el['colPos'],
'slot' => in_array((int)$el['colPos'], self::HERO_COLPOS, true) ? 'hero' : 'main',
'hidden' => (int)$el['hidden'] === 1,
'header' => (string)($el['header'] ?? ''),
'layoutVariant' => (string)($el['vitec_layout_variant'] ?? ''),
'texts' => $texts,
'links' => $links,
'images' => $this->files('tt_content', (int)$el['uid']),
'settings' => $settings,
];
}
$children = [];
if ((int)$p['_level'] === 1) {
foreach ($this->pages($cfg) as $c) {
if ((int)$c['pid'] === $uid) {
$children[] = ['uid' => (int)$c['uid'], 'title' => (string)$c['title'], 'url' => (string)$c['slug'], 'hidden' => (int)$c['hidden'] === 1];
}
}
}
return $this->base($p, $type, $record, $hiddenCount) + [
'seo' => [
'title' => (string)$p['title'],
'seoTitle' => (string)($p['seo_title'] ?? ''),
'description' => (string)($p['description'] ?? ''),
'og' => $this->files('pages', $uid, ['og_image']),
],
'media' => $this->files('pages', $uid, ['media']),
'record' => $record === null ? null : [
'uid' => (int)$record['uid'],
'title' => (string)$record['title'],
'subtitle' => (string)($record['subtitle'] ?? ''),
'teaser' => (string)($record['teaser'] ?? ''),
'description' => (string)($record['description'] ?? ''),
'image' => $this->files($cfg['recordTable'], (int)$record['uid'], ['image']),
],
'elements' => $elements,
'children' => $children,
];
}
}
return null;
}
// ---- checks -------------------------------------------------------------
/**
* @param array<int, array<string, mixed>> $visible visible elements in page order
* @param array{n: array<int, array<string, int>>, ph: array<int, array<string, int>>} $elementFiles n/ph: [elementUid][fieldname] = count of all / of placeholder files
* @param array{n: array<string, int>, ph: array<string, int>} $pageFiles [fieldname] = count
* @param array<string, mixed> $page
* @param array<string, mixed>|null $record
* @param array{n: array<string, int>, ph: array<string, int>} $recordFiles
* @return array<string, array{ok: bool, count: int, icons?: int, placeholder?: int}>
*/
private function pageChecks(string $type, array $visible, array $elementFiles, array $pageFiles, array $page, ?array $record, array $recordFiles, int $allStories, int $allCustomers): array
{
$hero = $this->firstOf($visible, 'vitec_markethero') ?? $this->firstOf($visible, 'vitec_herosection');
$heroFiles = $hero ? ($elementFiles['n'][(int)$hero['uid']] ?? []) : [];
$heroPh = $hero ? ($elementFiles['ph'][(int)$hero['uid']] ?? []) : [];
$heroFields = ['vitec_bgimage', 'vitec_hero_bgimage', 'vitec_hero_image'];
$lead = null;
foreach ($visible as $el) {
if ($el['CType'] === 'vitec_introparagraph' && in_array((int)$el['colPos'], self::HERO_COLPOS, true)) {
$lead = $el;
break;
}
}
$intro = $this->firstOf($visible, 'vitec_marketintro');
$introFiles = $intro ? ($elementFiles['n'][(int)$intro['uid']] ?? []) : [];
$introPh = $intro ? ($elementFiles['ph'][(int)$intro['uid']] ?? []) : [];
$cards = array_values(array_filter($visible, static fn(array $e): bool => $e['CType'] === 'vitec_card'));
$cardVisuals = 0;
$cardPlaceholders = 0;
foreach ($cards as $c) {
$f = $elementFiles['n'][(int)$c['uid']] ?? [];
$ph = $elementFiles['ph'][(int)$c['uid']] ?? [];
if (($f['vitec_icon'] ?? 0) > 0 || ($f['vitec_image'] ?? 0) > 0) {
$cardVisuals++;
}
if (($ph['vitec_icon'] ?? 0) > 0 || ($ph['vitec_image'] ?? 0) > 0) {
$cardPlaceholders++;
}
}
$stories = $this->firstOf($visible, 'vitec_usecaselist');
$storyCount = 0;
if ($stories) {
$sel = $this->flexSettings((string)$stories['pi_flexform']);
$selected = GeneralUtility::intExplode(',', (string)($sel['usecases'] ?? ''), true);
$storyCount = $selected === [] ? $allStories : count($selected);
}
$productCount = 0;
$hasProducts = false;
foreach ($visible as $el) {
if ($el['CType'] === 'vitec_productfeature') {
$hasProducts = true;
$productCount += (int)(($this->flexSettings((string)$el['pi_flexform'])['product'] ?? 0) > 0);
} elseif ($el['CType'] === 'vitec_productlist') {
$hasProducts = true;
$sel = $this->flexSettings((string)$el['pi_flexform']);
$productCount += count(GeneralUtility::intExplode(',', (string)($sel['selectedproducts'] ?? ''), true));
}
}
$insights = $this->firstOf($visible, 'vitec_marketinsights');
$insightsFiles = $insights ? ($elementFiles['n'][(int)$insights['uid']] ?? []) : [];
$insightsPh = $insights ? ($elementFiles['ph'][(int)$insights['uid']] ?? []) : [];
$logos = $this->firstOf($visible, 'vitec_customerlogos');
$logoCount = 0;
if ($logos) {
$sel = $this->flexSettings((string)$logos['pi_flexform']);
$selected = GeneralUtility::intExplode(',', (string)($sel['customers'] ?? ''), true);
$logoCount = $selected === [] || ($sel['onlySelected'] ?? '0') !== '1' ? $allCustomers : count($selected);
}
$cta = $this->firstOf($visible, 'vitec_ctabanner');
$recordFilled = 0;
if ($record !== null) {
$recordFilled += (($recordFiles['n']['image'] ?? 0) > 0) ? 1 : 0;
$recordFilled += $this->filled($record['teaser'] ?? '') ? 1 : 0;
$recordFilled += $this->filled($record['description'] ?? '') ? 1 : 0;
}
$sum = static fn(array $counts, array $fields): int => array_sum(array_map(static fn(string $f): int => (int)($counts[$f] ?? 0), $fields));
return [
'hero' => $this->check($sum($heroFiles, $heroFields), $sum($heroPh, $heroFields)),
'subline' => $this->check($hero && ($this->filled($hero['vitec_subline'] ?? '') || $this->filled($hero['subheader'] ?? '')) ? 1 : 0),
'lead' => $this->check($lead && $this->filled($lead['bodytext'] ?? '') ? 1 : 0),
'intro' => $this->check($intro && $this->filled($intro['vitec_intro_text'] ?? '') ? 1 : 0),
'introImage' => $this->check($introFiles['vitec_image'] ?? 0, $introPh['vitec_image'] ?? 0),
'introCta' => $this->check($intro && $this->filled($intro['vitec_learnmore_label'] ?? '') && $this->filled($intro['vitec_learnmore_target'] ?? '') ? 1 : 0),
'cards' => $this->check(count($cards), $cardPlaceholders) + ['icons' => $cardVisuals],
'stories' => ['ok' => $stories !== null, 'count' => $storyCount],
'products' => ['ok' => $hasProducts, 'count' => $productCount],
'insights' => $this->check($insights && $this->filled($insights['vitec_text1'] ?? '') ? 1 : 0),
'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0, $insightsPh['vitec_video_cover'] ?? 0),
'insightsCta' => $this->check($insights && $this->filled($insights['vitec_cta_label'] ?? '') && $this->filled($insights['vitec_cta_target'] ?? '') ? 1 : 0),
'logos' => ['ok' => $logos !== null, 'count' => $logoCount],
'cta' => $this->check($cta && $this->filled($cta['vitec_primary_cta_label'] ?? '') ? 1 : 0),
'seo' => $this->check($this->filled($page['description'] ?? '') ? 1 : 0),
'og' => $this->check($pageFiles['n']['og_image'] ?? 0, $pageFiles['ph']['og_image'] ?? 0),
'media' => $this->check($pageFiles['n']['media'] ?? 0, $pageFiles['ph']['media'] ?? 0),
'record' => ['ok' => $record !== null && ($recordFiles['n']['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled]
+ ((($recordFiles['ph']['image'] ?? 0) > 0) ? ['placeholder' => (int)$recordFiles['ph']['image']] : []),
];
}
// ---- building blocks ----------------------------------------------------
/**
* @param array<string, mixed> $p
* @param array<string, mixed>|null $record
*/
private function base(array $p, string $type, ?array $record, int $hiddenElements): array
{
$flags = [];
if ((int)$p['hidden'] === 1) {
$flags[] = 'hidden';
}
if ((int)$p['nav_hide'] === 1) {
$flags[] = 'navHide';
}
if ((int)$p['no_index'] === 1) {
$flags[] = 'noindex';
}
if ($record === null) {
$flags[] = 'noRecord';
}
if ($hiddenElements > 0) {
$flags[] = 'hiddenElements:' . $hiddenElements;
}
return [
'uid' => (int)$p['uid'],
'title' => (string)$p['title'],
'slug' => trim((string)$p['slug'], '/'),
'url' => (string)$p['slug'],
'type' => $type,
'level' => (int)$p['_level'],
'parent' => (int)$p['pid'],
'flags' => $flags,
];
}
/**
* @param int $placeholder number of placeholder files among $count; emitted only when > 0
* @return array{ok: bool, count: int, placeholder?: int}
*/
private function check(int $count, int $placeholder = 0): array
{
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
}
private function filled(mixed $value): bool
{
return trim(strip_tags((string)$value)) !== '';
}
/** @param array<int, array<string, mixed>> $elements */
private function firstOf(array $elements, string $cType): ?array
{
foreach ($elements as $el) {
if ($el['CType'] === $cType) {
return $el;
}
}
return null;
}
/** @return array<string, string> */
private function flexSettings(string $xml): array
{
if (trim($xml) === '') {
return [];
}
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
}
/**
* The scope's pages: level 1 = children of the root page, level 2 = their
* children. Standard pages only, in tree order; a page carrying the check
* element itself is left out.
*
* @param array{rootSlug: string, types: array<int, string>} $cfg
* @return array<int, array<string, mixed>> rows with _level and _type
*/
private function pages(array $cfg): array
{
$conn = $this->connection('pages');
$root = $conn->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$cfg['rootSlug']]);
if ($root === false) {
return [];
}
$select = 'SELECT uid, pid, title, slug, hidden, nav_hide, no_index, description, seo_title, sorting FROM pages
WHERE deleted = 0 AND sys_language_uid = 0 AND doktype = 1 AND pid IN (%s) ORDER BY pid, sorting';
$level1 = $conn->fetchAllAssociative(sprintf($select, (int)$root['uid']));
$checkPages = $this->pagesWithCheckElement();
$out = [];
foreach ($level1 as $p1) {
if (isset($checkPages[(int)$p1['uid']])) {
continue;
}
$out[] = $p1 + ['_level' => 1, '_type' => $cfg['types'][0]];
$level2 = $conn->fetchAllAssociative(sprintf($select, (int)$p1['uid']));
foreach ($level2 as $p2) {
if (isset($checkPages[(int)$p2['uid']])) {
continue;
}
$out[] = $p2 + ['_level' => 2, '_type' => $cfg['types'][1]];
}
}
return $out;
}
/** @return array<int, true> page uid => true for pages that hold a check element */
private function pagesWithCheckElement(): array
{
$out = [];
foreach ($this->connection('tt_content')->fetchFirstColumn(
"SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN ('vitec_pagecheck', 'vitec_productcheck')"
) as $pid) {
$out[(int)$pid] = true;
}
return $out;
}
/**
* All non-deleted default-language elements of the pages, page order,
* containers themselves left out (their children count as page content).
*
* @param int[] $pageUids
* @return array<int, array<int, array<string, mixed>>> [pageUid] = rows
*/
private function elementsByPage(array $pageUids): array
{
if ($pageUids === []) {
return [];
}
$rows = $this->connection('tt_content')->fetchAllAssociative(
'SELECT * FROM tt_content WHERE deleted = 0 AND sys_language_uid = 0 AND pid IN ('
. implode(',', array_map('intval', $pageUids)) . ') ORDER BY pid, colPos, sorting, uid'
);
// page order: hero slot first, then the main column(s) by colPos and sorting
usort($rows, static function (array $a, array $b): int {
$heroA = in_array((int)$a['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
$heroB = in_array((int)$b['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
return [(int)$a['pid'], $heroA, (int)$a['colPos'], (int)$a['sorting'], (int)$a['uid']]
<=> [(int)$b['pid'], $heroB, (int)$b['colPos'], (int)$b['sorting'], (int)$b['uid']];
});
$out = [];
foreach ($rows as $row) {
$cType = (string)$row['CType'];
foreach (self::CONTAINER_PREFIXES as $prefix) {
if (str_starts_with($cType, $prefix)) {
continue 2;
}
}
$out[(int)$row['pid']][] = $row;
}
return $out;
}
/**
* Visible file references per record and field.
*
* @param int[] $uids
* @return array<int, array<string, int>> [uid][fieldname] = count
*/
private function referenceCounts(string $table, array $uids, bool $placeholdersOnly = false): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$sql = $placeholdersOnly
? 'SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign IN (' . $in . ') AND ' . self::PLACEHOLDER_SQL . '
GROUP BY r.uid_foreign, r.fieldname'
: 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . $in . ')
GROUP BY uid_foreign, fieldname';
$rows = $this->connection('sys_file_reference')->fetchAllAssociative($sql, [$table]);
$out = [];
foreach ($rows as $r) {
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
}
return $out;
}
/**
* Files of one record with original URL and a ~480 px thumbnail.
*
* @param string[]|null $fieldnames null = all fields
* @return array<int, array{field: string, url: string, thumb: string, name: string, mime: string, placeholder: bool}>
*/
private function files(string $table, int $uid, ?array $fieldnames = null): array
{
$sql = "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign = ?";
$params = [$table, $uid];
if ($fieldnames !== null) {
$sql .= ' AND r.fieldname IN (' . implode(',', array_fill(0, count($fieldnames), '?')) . ')';
$params = array_merge($params, $fieldnames);
}
$sql .= ' ORDER BY r.fieldname, r.sorting_foreign';
$out = [];
foreach ($this->connection('sys_file_reference')->fetchAllAssociative($sql, $params) as $r) {
$url = '/fileadmin' . $r['identifier'];
$out[] = [
'field' => (string)preg_replace('/^vitec_/', '', (string)$r['fieldname']),
'url' => $url,
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
'name' => (string)$r['name'],
'mime' => (string)$r['mime_type'],
'placeholder' => self::isPlaceholder((string)$r['identifier'], (string)$r['name']),
];
}
return $out;
}
private function thumbnail(int $fileUid, string $mime, string $fallback): string
{
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return $fallback;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
return $imageService->getImageUri($processed);
} catch (\Throwable) {
return $fallback;
}
}
/**
* Records pointing to the pages via detail_page (first live record per page).
*
* @param int[] $pageUids
* @return array<int, array<string, mixed>> [pageUid] = record row
*/
private function records(string $table, array $pageUids): array
{
if ($pageUids === []) {
return [];
}
$rows = $this->connection($table)->fetchAllAssociative(
'SELECT uid, title, subtitle, teaser, description, detail_page FROM ' . $table . '
WHERE deleted = 0 AND detail_page IN (' . implode(',', array_map('intval', $pageUids)) . ') ORDER BY hidden, uid'
);
$out = [];
foreach ($rows as $r) {
$out[(int)$r['detail_page']] ??= $r;
}
return $out;
}
private function countAll(string $table, string $where): int
{
return (int)$this->connection($table)->fetchOne("SELECT COUNT(*) FROM $table WHERE $where");
}
private function connection(string $table): Connection
{
return $this->connectionPool->getConnectionForTable($table);
}
}
@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Product content audit as data: which product record has which content,
* and the full content of one product for the detail pane.
*
* This is the data model behind the standalone page public/product-check/
* (PHP + PDO, 2026-11-03), ported to Doctrine so the React frontend can
* render the same audit as a page (/products/product-check, content
* element vitec_productcheck, see ProductCheckJsonRenderer). Both read the
* database fresh on every call; nothing here is cached on purpose.
*
* overview(): every product (alphabetical) with one check per field
* {ok, count[, icons]} plus the per-field summary.
* detail(): one product with all texts (RTE HTML as stored), images
* (original URL + ~480 px thumbnail), videos, feature cards
* with icons, resources, datasheets, solutions.
*
* Placeholders (Harry 2026-11-13, same rule as PageCheckService): files below
* fileadmin/placeholders/ or named "...placeholder..." do not pass as
* finished. Image cells carry `placeholder` = number of such files (only when
* > 0), summary.filled counts only cells without placeholders and
* summary.placeholder the cells that have one; detail images carry
* `placeholder: true|false`.
*/
final class ProductCheckService
{
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
/**
* Columns of the audit table, in display order. kind drives the cell
* format in the frontend: images/video/features/list show a count,
* text shows a plain check.
*/
private const FIELDS = [
['key' => 'hero', 'label' => 'Hero', 'kind' => 'images'],
['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'],
['key' => 'og', 'label' => 'OG image', 'kind' => 'images'],
['key' => 'video', 'label' => 'Video', 'kind' => 'video'],
['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'],
['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'],
['key' => 'description', 'label' => 'Description', 'kind' => 'text'],
['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'],
['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'],
['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'],
['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'],
['key' => 'features', 'label' => 'Features', 'kind' => 'features'],
['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'],
['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'],
['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'],
];
private const FLAGS = [
'hidden' => 'hidden',
'legacy' => 'legacy',
'supportproduct' => 'support',
'subproduct' => 'sub',
'hideonwebsite' => 'hideWeb',
'hideonproducts' => 'hideList',
'showdatapath' => 'datapath',
];
private const TEXT_FIELDS = [
'teaser' => 'Teaser',
'subtitle' => 'Subtitle',
'description' => 'Description',
'description2' => 'Description 2',
'highlights' => 'Highlights',
'capabilities' => 'Capabilities',
'applications' => 'Applications',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
/**
* @return array{generatedAt: string, fields: array<int, array{key: string, label: string, kind: string}>, summary: array{products: int, filled: array<string, int>}, products: array<int, array<string, mixed>>}
*/
public function overview(): array
{
$products = $this->products();
$uids = array_map(static fn(array $p): int => (int)$p['uid'], $products);
$files = $this->fileCounts($uids);
$placeholders = $this->fileCounts($uids, true);
$features = $this->featureCounts($uids);
$resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0');
$datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download');
$solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution');
$filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
$placeholder = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
$rows = [];
foreach ($products as $p) {
$uid = (int)$p['uid'];
$f = $files[$uid] ?? [];
$ph = $placeholders[$uid] ?? [];
$checks = [
'hero' => $this->check($f['heroimage'] ?? 0, $ph['heroimage'] ?? 0),
'photos' => $this->check($f['productimage'] ?? 0, $ph['productimage'] ?? 0),
'og' => $this->check($f['ogimage'] ?? 0, $ph['ogimage'] ?? 0),
'video' => $this->check($f['videofile'] ?? 0),
'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0),
'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0),
'description' => $this->check($this->filled($p['description']) ? 1 : 0),
'description2' => $this->check($this->filled($p['description2']) ? 1 : 0),
'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0),
'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0),
'applications' => $this->check($this->filled($p['applications']) ? 1 : 0),
'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0],
'resources' => $this->check($resources[$uid] ?? 0),
'datasheets' => $this->check($datasheets[$uid] ?? 0),
'solutions' => $this->check($solutions[$uid] ?? 0),
];
foreach ($checks as $key => $c) {
if (($c['placeholder'] ?? 0) > 0) {
$placeholder[$key]++;
} elseif ($c['ok']) {
$filled[$key]++;
}
}
$rows[] = $this->base($p) + ['checks' => $checks];
}
return [
'generatedAt' => date(\DATE_ATOM),
'fields' => self::FIELDS,
'summary' => ['products' => count($rows), 'filled' => $filled, 'placeholder' => $placeholder],
'products' => $rows,
];
}
/**
* @return array<string, mixed>|null null when the product does not exist
*/
public function detail(int $uid): ?array
{
$p = $this->connection()->executeQuery(
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources_layout,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?',
[$uid]
)->fetchAssociative();
if ($p === false) {
return null;
}
$files = $this->files($uid);
$texts = [];
foreach (self::TEXT_FIELDS as $field => $label) {
$texts[$field] = [
'label' => $label,
'ok' => $this->filled($p[$field]),
'html' => (string)$p[$field],
];
}
return $this->base($p) + [
'images' => [
'hero' => $files['heroimage'] ?? [],
'photos' => $files['productimage'] ?? [],
'og' => $files['ogimage'] ?? [],
],
'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []),
'texts' => $texts,
'features' => $this->features($uid),
'resources' => $this->resources($uid),
'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid),
'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid),
];
}
// ---- building blocks ----------------------------------------------------
/** @param array<string, mixed> $p */
private function base(array $p): array
{
$slug = trim((string)$p['slug'], '/');
$flags = [];
foreach (self::FLAGS as $column => $label) {
if ((int)($p[$column] ?? 0) === 1) {
$flags[] = $label;
}
}
return [
'uid' => (int)$p['uid'],
'title' => (string)$p['title'],
'slug' => $slug,
'url' => '/product/' . $slug,
'flags' => $flags,
'resourcesLayout' => (string)($p['resources_layout'] ?? ''),
];
}
/** @return array{ok: bool, count: int} */
private function check(int $count, int $placeholder = 0): array
{
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
}
private function filled(mixed $value): bool
{
return trim(strip_tags((string)$value)) !== '';
}
/** @return array<int, array<string, mixed>> */
private function products(): array
{
return $this->connection()->executeQuery(
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources_layout,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid'
)->fetchAllAssociative();
}
/**
* Visible FAL references per product and field.
*
* @param int[] $uids
* @return array<int, array<string, int>> [productUid][fieldname] = count
*/
private function fileCounts(array $uids, bool $placeholdersOnly = false): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$sql = $placeholdersOnly
? "SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
AND r.uid_foreign IN (" . $in . ') AND ' . PageCheckService::PLACEHOLDER_SQL . '
GROUP BY r.uid_foreign, r.fieldname'
: "SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product'
AND fieldname IN ('heroimage','productimage','ogimage','videofile')
AND uid_foreign IN (" . $in . ')
GROUP BY uid_foreign, fieldname';
$rows = $this->connection()->executeQuery($sql)->fetchAllAssociative();
$out = [];
foreach ($rows as $r) {
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
}
return $out;
}
/**
* Files of one product with original URL and a ~480 px thumbnail
* (SVG and non-images keep the original as thumbnail).
*
* @return array<string, array<int, array{url: string, thumb: string, name: string, mime: string, placeholder: bool}>>
*/
private function files(int $uid): array
{
$rows = $this->connection()->executeQuery(
"SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ?
ORDER BY r.fieldname, r.sorting_foreign",
[$uid]
)->fetchAllAssociative();
$out = [];
foreach ($rows as $r) {
$url = '/fileadmin' . $r['identifier'];
$out[(string)$r['fieldname']][] = [
'url' => $url,
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
'name' => (string)$r['name'],
'mime' => (string)$r['mime_type'],
'placeholder' => PageCheckService::isPlaceholder((string)$r['identifier'], (string)$r['name']),
];
}
return $out;
}
private function thumbnail(int $fileUid, string $mime, string $fallback): string
{
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return $fallback;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
return $imageService->getImageUri($processed);
} catch (\Throwable) {
return $fallback;
}
}
/**
* @param int[] $uids
* @return array<int, array{cards: int, icons: int}>
*/
private function featureCounts(array $uids): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product"
)->fetchAllAssociative() as $r) {
$out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0];
}
foreach ($this->connection()->executeQuery(
"SELECT fe.product, COUNT(DISTINCT fe.uid) AS n
FROM tx_vitec_product_feature fe
JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature'
AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0
WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product"
)->fetchAllAssociative() as $r) {
$out[(int)$r['product']]['icons'] = (int)$r['n'];
}
return $out;
}
/** @return array<int, array{title: string, text: string, icon: array{url: string, name: string}|null}> */
private function features(int $uid): array
{
$cards = $this->connection()->executeQuery(
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$uid]
)->fetchAllAssociative();
if ($cards === []) {
return [];
}
$icons = [];
foreach ($this->connection()->executeQuery(
"SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon'
AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ')
ORDER BY r.uid_foreign, r.sorting_foreign'
)->fetchAllAssociative() as $r) {
$icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
}
return array_map(static fn(array $c): array => [
'title' => (string)$c['title'],
'text' => (string)$c['cardtext'],
'icon' => $icons[(int)$c['uid']] ?? null,
], $cards);
}
/** @return array<int, array{title: string, type: string, link: string, text: string}> */
private function resources(int $uid): array
{
return array_map(static fn(array $r): array => [
'title' => (string)$r['title'],
'type' => (string)$r['restype'],
'link' => (string)$r['link'],
'text' => (string)$r['cardtext'],
], $this->connection()->executeQuery(
'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$uid]
)->fetchAllAssociative());
}
/** @return array<int, int> [productUid] = count */
private function countBy(string $table, string $column, string $where): array
{
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column"
)->fetchAllAssociative() as $r) {
$out[(int)$r['k']] = (int)$r['n'];
}
return $out;
}
/** @return array<int, int> [productUid] = count of non-deleted foreign records */
private function mmCounts(string $mmTable, string $foreignTable): array
{
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm
JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
GROUP BY mm.uid_local"
)->fetchAllAssociative() as $r) {
$out[(int)$r['k']] = (int)$r['n'];
}
return $out;
}
/** @return array<int, array{uid: int, title: string}> */
private function mmTitles(string $mmTable, string $foreignTable, int $uid): array
{
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery(
"SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
WHERE mm.uid_local = ? ORDER BY mm.sorting",
[$uid]
)->fetchAllAssociative());
}
private function connection(): \TYPO3\CMS\Core\Database\Connection
{
return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE);
}
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Keeps the SSR routing flag file in sync with the site setting
* "vitec.ssr.enabled" (Site Set switch, editable in the backend under
* Site Management > Settings).
*
* The flag file is what public/.htaccess checks per request (RewriteCond
* ... -f): if it exists, page requests are proxied to the SSR node on the
* Solr VPS; if not, the SPA fallback serves _frontend/index.html as
* before. TYPO3 itself never routes differently - the flag only steers
* Apache, which is why a plain file is used: zero runtime cost in the
* hot path and trivially inspectable on the server.
*
* Saving site settings in the TYPO3 v14 backend fires NO event (core
* SiteWriter::writeSettings dispatches nothing, checked 2026-11-09), so
* the sync runs self-healingly from the SyncSsrFlag middleware on every
* frontend and backend request - a mismatch costs one file operation
* once, agreement costs a file_exists(). The vitec:ssr-flag CLI command
* offers the same sync plus a status view.
*/
final class SsrFlagService
{
public function __construct(private readonly SiteFinder $siteFinder)
{
}
public function getFlagPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-on.flag';
}
/**
* True as soon as ANY site enables the setting (there is only the
* "vitec" site; iterating keeps us independent of the identifier).
*/
public function isEnabledInSettings(): bool
{
try {
foreach ($this->siteFinder->getAllSites() as $site) {
if ((bool)$site->getSettings()->get('vitec.ssr.enabled', false)) {
return true;
}
}
} catch (SiteNotFoundException) {
// No sites configured - treat as disabled.
}
return false;
}
public function isFlagSet(): bool
{
return is_file($this->getFlagPath());
}
/**
* Creates or removes the flag file so it matches the setting.
*
* @return bool|null The new state if something changed, null if the
* flag already matched the setting.
*/
public function sync(): ?bool
{
$desired = $this->isEnabledInSettings();
if ($desired === $this->isFlagSet()) {
return null;
}
if ($desired) {
file_put_contents(
$this->getFlagPath(),
"SSR routing is ON (site setting vitec.ssr.enabled).\n"
. "Managed by SsrFlagService - do not create/delete manually,\n"
. "flip the switch in Site Management > Settings instead.\n"
. 'Last sync: ' . date('c') . "\n"
);
// Fresh ON: whatever the node cached while the switch was OFF
// is stale by definition - drop its whole page cache once.
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class)
->purgeQuietly(['*'], 'switch-on');
} else {
@unlink($this->getFlagPath());
}
return $desired;
}
/**
* Auto-failover (vitec:ssr-health): turns SSR delivery hard OFF.
*
* Removing only the flag file would not stick - the self-healing
* SyncSsrFlag middleware recreates it from the still-true site setting
* on the very next request. So the setting itself is flipped to false
* in config/sites/<identifier>/settings.yaml (the file the backend
* settings editor writes, flat dotted keys), caches are flushed so the
* compiled site settings forget the old value, and only then the flag
* is removed. Re-enabling is deliberately manual (backend switch or
* settings.yaml + cache flush).
*/
public function disableSsr(): void
{
$changed = false;
foreach ($this->siteFinder->getAllSites() as $site) {
$file = Environment::getConfigPath() . '/sites/' . $site->getIdentifier() . '/settings.yaml';
if (!is_file($file)) {
continue;
}
$yaml = (string)file_get_contents($file);
$new = preg_replace('/^(vitec\.ssr\.enabled\s*:).*$/m', '$1 false', $yaml, 1, $count);
if ($new !== null && $count > 0 && $new !== $yaml) {
if (file_put_contents($file, $new) === false) {
throw new \RuntimeException('Could not write ' . $file, 1762800001);
}
$changed = true;
}
}
if (!$changed && $this->isEnabledInSettings()) {
throw new \RuntimeException(
'vitec.ssr.enabled not found in any config/sites/*/settings.yaml - cannot disable it, '
. 'and the SyncSsrFlag middleware would recreate the flag from the setting',
1762800002
);
}
if ($changed) {
GeneralUtility::makeInstance(CacheManager::class)->flushCaches();
}
@unlink($this->getFlagPath());
}
/**
* Sync variant for the request hot path: never lets an IO hiccup
* (read-only FS, concurrent unlink ...) break request handling.
*/
public function syncQuietly(): void
{
try {
$this->sync();
} catch (\Throwable) {
// Intentionally silent - next request retries.
}
}
}
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
* keeps a log of every attempt in tx_vitec_ssrpurgelog.
*
* Endpoint + secret live OUTSIDE the repository in var/vitec-ssr-purge.env
* (KEY=VALUE lines: SSR_PURGE_ENDPOINT, SSR_PURGE_SECRET, SSR_HEALTH_ENDPOINT,
* SSR_ALERT_EMAIL, SSR_FAILOVER_AFTER)
* - same pattern as the routing flag file, nothing secret in git.
*/
final class SsrPurgeService
{
private ?array $config = null;
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly SiteFinder $siteFinder,
) {}
public static function getConfigPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-purge.env';
}
public function isConfigured(): bool
{
$c = $this->getConfig();
return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== '';
}
public function getHealthEndpoint(): string
{
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
}
/**
* The /__stats sibling of the health endpoint (cumulative hit/miss/
* error counters, in the node since release 20261109-211544). Derived
* from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly.
*/
public function getStatsEndpoint(): string
{
$explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$health = $this->getHealthEndpoint();
return $health === '' ? '' : str_replace('/__health', '/__stats', $health);
}
/**
* Comma-separated recipient list for vitec:ssr-health transition and
* failover alerts (SSR_ALERT_EMAIL); empty string = alerting off.
*/
public function getAlertEmail(): string
{
return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? '');
}
/**
* Who gets the vitec:ssr-health alert mails. Maintained by editors in
* Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
* address per entry); the first site with at least one valid address
* wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
* fallback. Invalid entries are skipped and reported back.
*
* @return array{recipients: list<string>, source: string, invalid: list<string>}
*/
public function getAlertRecipients(): array
{
$invalid = [];
try {
foreach ($this->siteFinder->getAllSites() as $site) {
$list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
if (is_string($list)) {
$list = GeneralUtility::trimExplode(',', $list, true);
}
$valid = [];
foreach ((array)$list as $address) {
$address = trim((string)$address);
if ($address === '') {
continue;
}
if (GeneralUtility::validEmail($address)) {
$valid[] = $address;
} else {
$invalid[] = $address;
}
}
if ($valid !== []) {
return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
}
}
} catch (\Throwable) {
// No site configuration readable (e.g. broken YAML): use the env fallback.
}
$env = array_values(array_filter(
GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
static fn(string $a): bool => GeneralUtility::validEmail($a)
));
return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
}
/**
* Consecutive DOWN samples after which vitec:ssr-health fails over to
* the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute
* cron cadence). Never below 1.
*/
public function getFailoverAfter(): int
{
return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2));
}
/**
* SSR_CACHE_MB of the node (/etc/vitec-ssr.env on the VPS) as the
* module knows it — the node does not report its limit in /__health.
* Optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env; 0 = unknown.
*/
public function getCacheLimitMb(): int
{
return max(0, (int)($this->getConfig()['SSR_CACHE_LIMIT_MB'] ?? 0));
}
/**
* POST /__warm on the node (contract 0066-FE), derived from
* SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly.
*/
public function getWarmEndpoint(): string
{
$explicit = $this->getConfig()['SSR_WARM_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$purge = $this->getConfig()['SSR_PURGE_ENDPOINT'] ?? '';
return $purge === '' ? '' : str_replace('/__purge', '/__warm', $purge);
}
/**
* Starts a background warm run on the node: no paths (or '*') = every
* page of sitemap.xml, otherwise exactly the given paths. The node
* answers at once and renders in the background; progress shows up as
* "warming" in /__health and /__stats. Not written to the purge log -
* a warm run evicts nothing, and the log feeds the purge statistics.
*
* @param string[] $paths
* @return array{success:bool, httpCode:int, message:string, data:array}
*/
public function warm(array $paths = []): array
{
if (!$this->isConfigured()) {
return ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'data' => []];
}
$paths = $this->normalize($paths);
$payload = ($paths === [] || $paths === ['*']) ? ['source' => 'sitemap'] : ['paths' => $paths];
try {
$response = $this->requestFactory->request($this->getWarmEndpoint(), 'POST', [
'headers' => [
'X-Purge-Secret' => $this->getConfig()['SSR_PURGE_SECRET'],
'Content-Type' => 'application/json',
],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
// The node reads the sitemap (index + sub-sitemaps) before
// it answers; the pages themselves are not awaited.
'timeout' => 30,
'connect_timeout' => 3,
'http_errors' => false,
]);
$code = $response->getStatusCode();
$body = (string)$response->getBody();
$data = json_decode($body, true);
$data = is_array($data) ? $data : [];
if ($code === 200 && !empty($data['started'])) {
$message = sprintf(
'Warm run started: %d pages queued (source: %s, %d at a time)',
(int)($data['queued'] ?? 0),
(string)($data['source'] ?? '?'),
(int)($data['concurrency'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
if ($code === 200 && !empty($data['running'])) {
$w = (array)($data['warming'] ?? []);
$message = sprintf(
'A warm run is already active: %d of %d pages done',
(int)($w['done'] ?? 0) + (int)($w['skipped'] ?? 0) + (int)($w['failed'] ?? 0),
(int)($w['queued'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
$message = 'HTTP ' . $code . ' - ' . (string)($data['error'] ?? substr($body, 0, 200));
return ['success' => false, 'httpCode' => $code, 'message' => $message, 'data' => $data];
} catch (\Throwable $e) {
return ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'data' => []];
}
}
/**
* Normalizes, dedupes and sends the paths; logs the outcome.
* Never throws - editors' saves must not fail on a purge problem.
*
* @param string[] $paths page paths, or ['*'] for everything
* @return array{success:bool, httpCode:int, message:string, paths:string[]}
*/
public function purge(array $paths, string $trigger): array
{
$paths = $this->normalize($paths);
if ($paths === []) {
return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []];
}
if (!$this->isConfigured()) {
$result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths];
$this->log($trigger, $result, 0);
return $result;
}
$c = $this->getConfig();
$start = microtime(true);
try {
$response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [
'headers' => [
'X-Purge-Secret' => $c['SSR_PURGE_SECRET'],
'Content-Type' => 'application/json',
],
'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR),
'timeout' => 4,
'connect_timeout' => 2,
'http_errors' => false,
]);
$code = $response->getStatusCode();
$body = substr((string)$response->getBody(), 0, 200);
$result = [
'success' => $code === 200,
'httpCode' => $code,
'message' => $body,
'paths' => $paths,
];
} catch (\Throwable $e) {
$result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths];
}
$this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000));
return $result;
}
public function purgeQuietly(array $paths, string $trigger): void
{
try {
$this->purge($paths, $trigger);
} catch (\Throwable) {
// Logged inside purge(); never disturb the caller.
}
}
/**
* Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query
* stripped, no trailing slash (root stays '/'), '*' wins over all.
*
* @param string[] $paths
* @return string[]
*/
public function normalize(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string)$p);
if ($p === '') {
continue;
}
if ($p === '*') {
return ['*'];
}
$p = parse_url($p, PHP_URL_PATH) ?: '/';
if ($p !== '/' ) {
$p = '/' . trim($p, '/');
}
$out[$p] = true;
}
return array_keys($out);
}
private function getConfig(): array
{
if ($this->config !== null) {
return $this->config;
}
$this->config = [];
$file = self::getConfigPath();
if (is_readable($file)) {
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$this->config[trim($k)] = trim($v);
}
}
return $this->config;
}
private function log(string $trigger, array $result, int $durationMs): void
{
try {
$this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert(
'tx_vitec_ssrpurgelog',
[
'crdate' => time(),
'trigger_source' => substr($trigger, 0, 100),
'paths' => implode(', ', $result['paths']),
'success' => $result['success'] ? 1 : 0,
'http_code' => $result['httpCode'],
'message' => substr($result['message'], 0, 255),
'duration_ms' => $durationMs,
]
);
} catch (\Throwable) {
// Table missing (before schema update) must not break anything.
}
}
}
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Targeted auto-purge (2026-11-12, Harry): which SSR paths show a given
* VITEC or news record, so saving one product no longer empties the
* whole SSR cache.
*
* A record shows up on
* 1. its own detail URL (route enhancers in config/sites/vitec/config.yaml),
* 2. pages whose content elements select it explicitly (sys_refindex),
* 3. pages with list plugins that pick records of its type on their own,
* 4. the pages of records that embed it (related products, product
* downloads, product solutions) - one level deep.
* Everything still goes ('*') when the change can touch links or menus on
* every page: URL, visibility or timing fields, the newest news and the
* events (the megamenu spotlight shows them live), unknown tables, or
* more than MAX_PATHS paths. When in doubt: '*'.
*/
final class SsrPurgeTargetResolver
{
public const ALL = '*';
private const MAX_PATHS = 150;
/** Changing one of these can change URLs, menus or visibility everywhere. */
private const GLOBAL_FIELDS = ['slug', 'path_segment', 'hidden', 'deleted', 'starttime', 'endtime',
'fe_group', 'pid', 'sys_language_uid', 'type', 'datetime', 'is_group'];
/** Backend-only tables: nothing of them is rendered. */
private const INTERNAL_TABLES = ['tx_vitec_form_submission', 'tx_vitec_import_mapping', 'tx_vitec_seo_research',
'tx_vitec_product_workbook', 'tx_vitec_ssrpurgelog', 'tx_vitec_ssrhealthlog', 'tx_vitec_clienterrorlog'];
/** Inline children: child table => [parent field, parent table]. */
private const CHILD_TABLES = [
'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
];
/** Shown in the megamenu or rarely changed and used broadly. */
private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
'tx_news_domain_model_tag'];
/** List plugins that show records of a table without a fixed selection. */
private const LIST_CTYPES = [
// Product lists (vitec_productlist) filter by category: productListPages().
'tx_vitec_domain_model_product' => ['vitec_productcheck'],
'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
'tx_vitec_domain_model_location' => ['vitec_locationlist'],
'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
];
/** Records that embed a record of the key table: [embedding table, field]. */
private const EMBEDDED_IN = [
'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
['tx_vitec_product_related_text', 'related_uid']],
'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
];
/** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
private const DETAIL_ROUTES = [
'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
];
/** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
private const SPOTLIGHT_NEWS = 5;
private const SPOTLIGHT_NEWS_PIDS = [11, 442];
/**
* @param string[] $changedFields field names of the save ([] = unknown)
* @return list<string> paths to purge, ['*'] for everything, [] for nothing visible
*/
public function resolve(string $table, int $uid, array $changedFields): array
{
try {
return $this->doResolve($table, $uid, $changedFields);
} catch (\Throwable) {
return [self::ALL];
}
}
private function doResolve(string $table, int $uid, array $changedFields): array
{
if (in_array($table, self::INTERNAL_TABLES, true)) {
return [];
}
if ($uid <= 0) {
return [self::ALL];
}
if (isset(self::CHILD_TABLES[$table])) {
[$field, $parentTable] = self::CHILD_TABLES[$table];
$parentUid = (int)$this->connection($table)->executeQuery(
'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
}
if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
return [self::ALL];
}
if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
return [self::ALL];
}
if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
return [self::ALL];
}
$paths = $this->pathsOf($table, $uid);
foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
$paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
}
}
foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
$paths[] = $this->pagePath($pid);
}
if ($table === 'tx_vitec_domain_model_product') {
foreach ($this->productListPages($uid) as $pid) {
$paths[] = $this->pagePath($pid);
}
}
$paths = array_values(array_unique(array_filter($paths)));
return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
}
/** Detail URL(s) plus the pages that select the record explicitly. */
private function pathsOf(string $table, int $uid): array
{
$paths = $this->detailPaths($table, $uid);
$pids = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
['tt_content', $table, $uid]
)->fetchFirstColumn();
foreach ($pids as $pid) {
$paths[] = $this->pagePath((int)$pid);
}
if ($table === 'tx_vitec_domain_model_solution') {
$detailPage = (int)$this->connection($table)->executeQuery(
'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
if ($detailPage > 0) {
$paths[] = $this->pagePath($detailPage);
}
}
return $paths;
}
/** @return list<array{0: string, 1: int}> records embedding the given one */
private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
{
if ($embeddingTable === 'tx_vitec_product_related_text') {
$rows = $this->connection($embeddingTable)->executeQuery(
'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
}
$rows = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
[$embeddingTable, $field, $table, $uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
}
private function detailPaths(string $table, int $uid): array
{
if (!isset(self::DETAIL_ROUTES[$table])) {
return [];
}
[$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
$paths = [];
foreach ($pageUids as $pageUid) {
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$paths[] = $site->getRouter()->generateUri($pageUid, [
$namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
])->getPath();
} catch (\Throwable) {
// Record not routable (hidden, no slug): its pages are covered by the lists.
}
}
return $paths;
}
private function pagesWithCTypes(array $cTypes): array
{
if ($cTypes === []) {
return [];
}
return array_map('intval', $this->connection('tt_content')->executeQuery(
'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
[$cTypes],
[Connection::PARAM_STR_ARRAY]
)->fetchFirstColumn());
}
/**
* Pages with a product list that can show the product. Mirrors
* ProductListJsonRenderer: selected products (settings.selectedproducts)
* show exactly those - covered by the refindex; otherwise a category
* filter matches the selected categories and their whole subtree;
* "all products" or no category shows every product.
*/
private function productListPages(int $productUid): array
{
// The product's categories plus all their ancestors.
$categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
"SELECT uid_local FROM sys_category_record_mm
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
[$productUid]
)->fetchFirstColumn());
$seen = [];
$queue = $categories;
while ($queue !== []) {
$cat = array_shift($queue);
if ($cat <= 0 || isset($seen[$cat])) {
continue;
}
$seen[$cat] = true;
$queue[] = (int)$this->connection('sys_category')->executeQuery(
'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
)->fetchOne();
}
$pages = [];
$lists = $this->connection('tt_content')->executeQuery(
"SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
)->fetchAllAssociative();
foreach ($lists as $list) {
$refs = $this->connection('sys_refindex')->executeQuery(
"SELECT ref_table, ref_uid FROM sys_refindex
WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
[(int)$list['uid']]
)->fetchAllAssociative();
$selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
if ($selected !== []) {
continue; // fixed selection: in the refindex pages already when it contains the product
}
$listCategories = array_map('intval', array_column(
array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
'ref_uid'
));
$allProducts = preg_match('#settings\.allproducts">\s*<value index="vDEF">1<#', (string)$list['pi_flexform']) === 1;
if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
$pages[] = (int)$list['pid'];
}
}
return array_values(array_unique($pages));
}
private function isSpotlightNews(int $uid): bool
{
$newest = $this->connection('tx_news_domain_model_news')->executeQuery(
'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
[self::SPOTLIGHT_NEWS_PIDS],
[Connection::PARAM_INT_ARRAY]
)->fetchFirstColumn();
return in_array($uid, array_map('intval', $newest), true);
}
private function pagePath(int $pageId): string
{
if ($pageId <= 0) {
return '';
}
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
} catch (\Throwable) {
return ''; // Folder or page outside a site: nothing cached for it.
}
}
private function connection(string $table): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
}
}
@@ -155,8 +155,40 @@ final class NewsJsonRenderer
return json_encode($response) ?: ''; return json_encode($response) ?: '';
} }
// Server-side pagination for the big overview list (contract
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
// WITHOUT a hand-picked selection paginates. That is exactly the
// /insights/news element (#130); every teaser list carries a
// limit (1-3) and keeps today's full behaviour, as do selected/
// search lists - the frontend falls back to client-side paging
// wherever the `pagination` key is absent.
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
&& (int)($settings['limit'] ?? 0) === 0
&& trim((string)($settings['selectedList'] ?? '')) === '';
if ($paginated
&& $this->cObj !== null
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
) {
// `page`/`category` sit in cacheHash.excludedParameters, so the
// TYPO3 page cache does NOT distinguish the query variants -
// a cacheable render would bake page 1 in for every URL. As
// USER_INT the element renders per request; EXT:headless wraps
// the INT marker itself (JsonContentObject detects
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
// variant per path+query, so per-request cost stays off the
// visitors' hot path.
$this->cObj->convertToUserIntObject();
return '';
}
$newsRows = $this->fetchNewsList($contentElement, $settings); $newsRows = $this->fetchNewsList($contentElement, $settings);
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows);
if ($paginated) {
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
}
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
$response = [ $response = [
'mode' => 'list', 'mode' => 'list',
@@ -323,12 +355,28 @@ final class NewsJsonRenderer
* @param array<string,mixed> $settings * @param array<string,mixed> $settings
* @return array<string,mixed> * @return array<string,mixed>
*/ */
private function serializeNews(array $news, array $settings = []): array private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array
{ {
$uid = (int)($news['uid'] ?? 0); $uid = (int)($news['uid'] ?? 0);
$pathSegment = (string)($news['path_segment'] ?? ''); $pathSegment = (string)($news['path_segment'] ?? '');
$urls = $this->buildNewsUrls($news, $settings); $urls = $this->buildNewsUrls($news, $settings);
// Detail view only (contract 0083-BE): the SEO articles carry a
// "Frequently asked questions" section as bare <p> pairs plus inline
// product links. The Q&A pairs move into a structured `faq` band
// (accordion in the frontend), linked products/categories become
// `mentionedProducts` cards; the body keeps everything else.
$detailBody = null;
$faqBand = null;
$mentionedProducts = [];
if ($mode !== 'list') {
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
$faqSplit = $this->extractFaqBand($detailBody);
$detailBody = $faqSplit['html'];
$faqBand = $faqSplit['faq'];
$mentionedProducts = $this->collectMentionedProducts($detailBody);
}
return [ return [
'uid' => $uid, 'uid' => $uid,
'title' => (string)($news['title'] ?? ''), 'title' => (string)($news['title'] ?? ''),
@@ -338,7 +386,17 @@ final class NewsJsonRenderer
'detailUrl' => $urls['detailUrl'], 'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'], 'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''), 'teaser' => (string)($news['teaser'] ?? ''),
'bodytext' => RteResolver::html($news['bodytext'] ?? ''), // List payloads do not carry the full article body: with ~600
// records that inflated /_json/insights/news to 2.6 MB (and the
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
// card goes blank. The detail mode is unchanged.
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
// paragraph) are split out of the body - they reach Google via
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: (string)$detailBody,
'datetime' => (int)($news['datetime'] ?? 0), 'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0), 'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false), 'istopnews' => (bool)($news['istopnews'] ?? false),
@@ -350,9 +408,375 @@ final class NewsJsonRenderer
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid), 'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
'related' => $this->resolveRelatedNews($uid), 'related' => $this->resolveRelatedNews($uid),
'relatedLinks' => $this->resolveRelatedLinks($uid), 'relatedLinks' => $this->resolveRelatedLinks($uid),
'faq' => $faqBand,
'mentionedProducts' => $mentionedProducts,
]; ];
} }
/**
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
* page's cards plus pagination meta, filter chips with full-list counts
* and the active category. Request: ?page=<n>&category=<slug> on the page
* URL; page is clamped into [1..pages] (page > pages serves the last
* page), an unknown category slug falls back to "all".
*
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
*/
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
{
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
// USER_INT renders on every request; the 12-card page payload (image
// processing included) took 1-6 s on cold SSR misses (Seobility: 50
// slow URLs, all ?page=N). Keep each page/category variant for 15 min
// in the hash cache - cache:flush clears it, news edits are rare.
$cache = null;
$cacheId = '';
try {
$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('hash');
$cacheId = 'vitec-newslist-' . (int)($contentElement['uid'] ?? 0)
. '-' . (int)($contentElement['sys_language_uid'] ?? 0)
. '-' . $requestedPage . '-' . md5($requestedCategory);
$cached = $cache->get($cacheId);
if (is_string($cached) && $cached !== '') {
return $cached;
}
} catch (\Throwable $e) {
$cache = null;
}
// All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels.
$categoriesByNews = $this->categoriesForNewsUids(
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
);
$categoryIndex = [];
foreach ($rows as $row) {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
$slug = $this->slugify((string)$category['title']);
if ($slug === '') {
continue;
}
if (!isset($categoryIndex[$slug])) {
$categoryIndex[$slug] = [
'uid' => (int)$category['uid'],
'title' => (string)$category['title'],
'slug' => $slug,
'count' => 0,
];
}
$categoryIndex[$slug]['count']++;
}
}
ksort($categoryIndex);
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
if ($activeCategory !== '') {
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
if ($this->slugify((string)$category['title']) === $activeCategory) {
return true;
}
}
return false;
}));
}
// Newest first, server-side (the flexform historically says
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
if ($itemsPerPage <= 0) {
$itemsPerPage = 12;
}
$total = count($rows);
$pages = max(1, (int)ceil($total / $itemsPerPage));
$page = min($requestedPage, $pages);
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
$items = array_map(
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
$pageRows
);
$response = [
'mode' => 'list',
'items' => $items,
'pagination' => [
'page' => $page,
'pages' => $pages,
'itemsPerPage' => $itemsPerPage,
'total' => $total,
],
'categories' => array_values($categoryIndex),
'activeCategory' => $activeCategory,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
$json = json_encode($response) ?: '';
if ($cache !== null && $json !== '') {
try {
$cache->set($cacheId, $json, ['tx_news'], 900);
} catch (\Throwable $e) {
// caching is best effort
}
}
return $json;
}
/**
* Lean card shape for paginated pages - only what the cards render
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
* deliberately absent here.
*
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $categories
* @return array<string,mixed>
*/
private function serializeListCard(array $news, array $settings, array $categories): array
{
$uid = (int)($news['uid'] ?? 0);
$urls = $this->buildNewsUrls($news, $settings);
$teaser = trim((string)($news['teaser'] ?? ''));
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'type' => (string)($news['type'] ?? '0'),
'detailUrl' => $urls['detailUrl'],
'datetime' => (int)($news['datetime'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'teaser' => $teaser,
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
'categories' => array_map(
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
$categories
),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
];
}
/**
* uid => categories map for a set of news records, resolved in one query
* (the per-record resolveCategories() would fire ~600 queries here).
*
* @param list<int> $newsUids
* @return array<int,array<int,array{uid:int,title:string}>>
*/
private function categoriesForNewsUids(array $newsUids): array
{
$newsUids = array_values(array_filter($newsUids));
if ($newsUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.uid', 'c.title', 'mm.uid_foreign')
->from('sys_category', 'c')
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid_foreign']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
];
}
return $map;
}
/**
* Category title -> filter slug ("Press Release" -> "press-release"),
* mirroring the slugs the page URLs already carry.
*/
private function slugify(string $value): string
{
$value = mb_strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
return trim($value, '-');
}
/**
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
*/
private function listExcerpt(string $html): string
{
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
if (mb_strlen($text) <= 300) {
return $text;
}
$cut = mb_substr($text, 0, 300);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > 180) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " .,;:") . '...';
}
/**
* Splits the trailing FAQ section out of an article body.
*
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
* with "Frequently asked" or "FAQ", followed by alternating question
* paragraphs (plain text ending in "?") and answer paragraphs (may
* contain links). Fail-safe: anything that does not yield at least two
* clean Q&A pairs leaves the HTML untouched.
*
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
*/
private function extractFaqBand(string $html): array
{
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
return ['html' => $html, 'faq' => null];
}
$heading = trim((string)$headingMatch[2][0]);
$sectionStart = (int)$headingMatch[0][1];
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
$sectionEnd = $cursor;
$items = [];
$question = null;
$answerParts = [];
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
$text = trim(strip_tags($paragraph[1]));
if ($text === '') {
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
continue;
}
if (str_ends_with($text, '?')) {
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
$question = $text;
$answerParts = [];
} elseif ($question !== null) {
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
} else {
break;
}
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
}
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
if (count($items) < 2) {
return ['html' => $html, 'faq' => null];
}
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
return [
'html' => trim($clean),
'faq' => ['heading' => $heading, 'items' => $items],
];
}
/**
* Cards for every product detail page (/product/<slug>) and product
* category page (/products/<slug>) linked inside an article body -
* order of first appearance, deduplicated, at most six.
*
* @return array<int,array<string,mixed>>
*/
private function collectMentionedProducts(string $html): array
{
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
return [];
}
$cards = [];
foreach ($matches as $match) {
$href = (string)$match[1];
if (isset($cards[$href]) || count($cards) >= 6) {
continue;
}
try {
if (!empty($match[2])) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
->from('tx_vitec_domain_model_product')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('hideonwebsite', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'product',
'title' => (string)($row['title'] ?? ''),
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
'link' => $href,
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
];
} else {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $qb->select('uid', 'title', 'description', 'abstract')
->from('pages')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'category',
'title' => (string)($row['title'] ?? ''),
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
'link' => $href,
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
];
}
} catch (\Throwable $e) {
continue;
}
}
return array_values($cards);
}
/** /**
* Where a news item actually points. * Where a news item actually points.
* *
@@ -673,6 +1097,9 @@ final class NewsJsonRenderer
$files[] = [ $files[] = [
'uid' => (int)$row['uid'], 'uid' => (int)$row['uid'],
'url' => $imageService->getImageUri($processed), 'url' => $imageService->getImageUri($processed),
// Editor-maintained alt text; the frontend falls back to
// the article title when this is empty (0070-FE, B5).
'alternative' => (string)($fileReference->getAlternative() ?? ''),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
continue; continue;
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\PageCheckService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* JSON for the content element `vitec_pagecheck` (pages /markets/market-check
* and /solutions/solution-check): the page content audit of one scope,
* rendered by the React front end like the product check.
*
* Emitted under content.pagecheck:
*
* without query {mode: "overview", scope, scopeLabel, generatedAt, fields[], summary, pages[]}
* ?check=<pageUid> {mode: "detail", page: {…} | null}
* on failure {mode: "error", error}
*
* The scope (markets | solutions) comes from the element's FlexForm
* (settings.scope). Always USER_INT - the audit shows the database as it is
* right now; `check` is excluded from the cHash (ext_localconf.php, shared
* with the product check). The front end loads the detail variant
* client-side from /_json, one request per opened row.
*/
final class PageCheckJsonRenderer
{
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
$this->cObj->convertToUserIntObject();
return '';
}
try {
$scope = 'markets';
$flex = (string)($this->cObj?->data['pi_flexform'] ?? '');
if (trim($flex) !== '') {
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($flex)['settings'] ?? [];
if (isset(PageCheckService::SCOPES[(string)($settings['scope'] ?? '')])) {
$scope = (string)$settings['scope'];
}
}
$service = GeneralUtility::makeInstance(PageCheckService::class);
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$check = (int)($queryParams['check'] ?? 0);
if ($check > 0) {
$payload = ['mode' => 'detail', 'scope' => $scope, 'page' => $service->detail($check)];
} else {
$payload = ['mode' => 'overview'] + $service->overview($scope);
}
} catch (\Throwable $e) {
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
}
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
}
@@ -78,8 +78,17 @@ final class PageJsonLdRenderer
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization); $nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
} }
// BreadcrumbList from the rootline. // BreadcrumbList from the rootline. On record detail pages the last
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base); // crumb is the generic shell page ("Story", "Product", "Article")
// and is swapped for the record itself (0108-FE, F11).
$args = $this->routeArguments($request);
$crumbs = $this->breadcrumbItems($rootLine);
$recordCrumb = $this->recordCrumb($args);
if ($recordCrumb !== null && $crumbs !== []) {
array_pop($crumbs);
$crumbs[] = $recordCrumb;
}
$breadcrumb = $service->buildBreadcrumbList($crumbs, $base);
if ($breadcrumb !== null) { if ($breadcrumb !== null) {
$nodes[] = $breadcrumb; $nodes[] = $breadcrumb;
} }
@@ -112,6 +121,59 @@ final class PageJsonLdRenderer
// Product on product detail pages (routed record, SEO audit 2026-11-09).
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($productArg !== '') {
$productRow = $this->fetchDetailRecord(
'tx_vitec_domain_model_product',
is_numeric($productArg) ? 'uid' : 'slug',
$productArg
);
if ($productRow !== null) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'productimage', (int)$productRow['uid'], $base, $service);
if ($productImages === []) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'ogimage', (int)$productRow['uid'], $base, $service);
}
$nodes[] = $service->buildProduct([
'uid' => (int)$productRow['uid'],
'title' => (string)($productRow['title'] ?? ''),
'teaser' => (string)($productRow['teaser'] ?? ''),
'description' => (string)($productRow['description'] ?? ''),
'slug' => (string)($productRow['slug'] ?? ''),
], $base, $productImages, $hasOrganization);
}
}
// NewsArticle on EXT:news detail pages (the shell page carries no record data).
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
if ($newsUid > 0) {
$newsRow = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
if ($newsRow !== null) {
$newsTitle = trim((string)($newsRow['alternative_title'] ?? ''));
if ($newsTitle === '') {
$newsTitle = (string)($newsRow['title'] ?? '');
}
$nodes[] = $service->buildNewsArticle([
'seo_title' => '',
'title' => $newsTitle,
'description' => trim(strip_tags((string)($newsRow['teaser'] ?? ''))),
'abstract' => '',
'crdate' => (int)($newsRow['datetime'] ?? 0),
'SYS_LASTCHANGED' => (int)($newsRow['tstamp'] ?? 0),
'slug' => '/news/article/' . (string)($newsRow['path_segment'] ?? ''),
'author' => (string)($newsRow['author'] ?? ''),
'author_email' => (string)($newsRow['author_email'] ?? ''),
], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization);
// Editor-pasted schema blocks (e.g. FAQPage JSON in a
// trailing bodytext paragraph) join the @graph here;
// NewsJsonRenderer strips them out of the rendered body.
foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) {
$nodes[] = $embeddedNode;
}
}
}
return $service->encodeGraph($nodes); return $service->encodeGraph($nodes);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ''; return '';
@@ -166,6 +228,40 @@ final class PageJsonLdRenderer
]; ];
} }
/**
* Breadcrumb item for the routed detail record (product, news, story), or
* null on plain pages.
*
* @param array<string,mixed> $args
* @return array{name:string, url:string}|null
*/
private function recordCrumb(array $args): ?array
{
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($productArg !== '') {
$row = $this->fetchDetailRecord('tx_vitec_domain_model_product', is_numeric($productArg) ? 'uid' : 'slug', $productArg);
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
return ['name' => (string)($row['title'] ?? ''), 'url' => '/product/' . $row['slug']];
}
}
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
if ($newsUid > 0) {
$row = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
if ($row !== null && (string)($row['path_segment'] ?? '') !== '') {
$name = trim((string)($row['alternative_title'] ?? '')) ?: (string)($row['title'] ?? '');
return ['name' => $name, 'url' => '/news/article/' . $row['path_segment']];
}
}
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
if ($usecaseUid > 0) {
$row = $this->fetchDetailRecord('tx_vitec_domain_model_usecase', 'uid', (string)$usecaseUid);
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
return ['name' => (string)($row['title'] ?? ''), 'url' => '/success-stories/' . $row['slug']];
}
}
return null;
}
/** /**
* Build ordered breadcrumb items (root → current) from the rootline. * Build ordered breadcrumb items (root → current) from the rootline.
* Skips spacers, folders and recyclers; uses nav_title with title fallback. * Skips spacers, folders and recyclers; uses nav_title with title fallback.
@@ -410,4 +506,77 @@ final class PageJsonLdRenderer
} }
} }
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments($request): array
{
$routing = $request->getAttribute('routing');
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
return array_replace_recursive($request->getQueryParams(), $routing->getArguments());
}
return $request->getQueryParams();
}
/**
* One visible record by uid or slug. Null when missing.
*
* @return array<string,mixed>|null
*/
private function fetchDetailRecord(string $table, string $field, string $value): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$row = $qb->select('*')->from($table)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$field === 'uid'
? $qb->expr()->eq('uid', $qb->createNamedParameter((int)$value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Absolute URLs of a record's FAL images (unprocessed originals).
*
* @return string[]
*/
private function recordImageUrls(string $table, string $field, int $uid, string $base, StructuredDataService $service): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$rows = $qb->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->executeQuery()->fetchAllAssociative();
$urls = [];
foreach ($rows as $row) {
$identifier = (string)($row['identifier'] ?? '');
if ($identifier !== '') {
$urls[] = $service->absUrl('/fileadmin' . $identifier, $base);
}
}
return $urls;
} catch (\Throwable $e) {
return [];
}
}
} }
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\ProductCheckService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* JSON for the content element `vitec_productcheck` (page /products/
* product-check): the product content audit, rendered by the React front
* end instead of the standalone page public/product-check/.
*
* Emitted under content.productcheck:
*
* without query {mode: "overview", generatedAt, fields[], summary, products[]}
* ?check=<uid> {mode: "detail", product: {…} | null}
* on failure {mode: "error", error}
*
* Always USER_INT: the audit must show the database as it is right now,
* so neither the TYPO3 page cache nor the `check` variant may stick.
* `check` is excluded from the cHash in ext_localconf.php. The front end
* is expected to load the detail variant client-side from /_json (one
* request per opened row), not to navigate the SSR page with the query.
*/
final class ProductCheckJsonRenderer
{
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
$this->cObj->convertToUserIntObject();
return '';
}
try {
$service = GeneralUtility::makeInstance(ProductCheckService::class);
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$check = (int)($queryParams['check'] ?? 0);
if ($check > 0) {
$payload = ['mode' => 'detail', 'product' => $service->detail($check)];
} else {
$payload = ['mode' => 'overview'] + $service->overview();
}
} catch (\Throwable $e) {
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
}
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
}
@@ -21,6 +21,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* *
* Extends ProductListJsonRenderer purely to reuse its serializeProduct() * Extends ProductListJsonRenderer purely to reuse its serializeProduct()
* and image handling - same filters, same shape, one source of truth. * and image handling - same filters, same shape, one source of truth.
*
* "No product" returns an EMPTY STRING, never json_encode(null): headless
* only decodes strings that look like objects/arrays, so the text "null"
* went out as the string "null" and crashed the front end (ticket
* 0068-FE). The TypoScript sets ifEmptyReturnNull = 1 on this field,
* which turns the empty string into a real JSON null.
*/ */
final class ProductFeatureJsonRenderer extends ProductListJsonRenderer final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
{ {
@@ -33,7 +39,7 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? '')); ->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
$productUid = (int)($flexFormData['settings']['product'] ?? 0); $productUid = (int)($flexFormData['settings']['product'] ?? 0);
if ($productUid <= 0) { if ($productUid <= 0) {
return (string)json_encode(null); return '';
} }
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -47,12 +53,12 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
) )
->executeQuery()->fetchAssociative(); ->executeQuery()->fetchAssociative();
if (!$product) { if (!$product) {
return (string)json_encode(null); return '';
} }
return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) { } catch (\Throwable) {
return (string)json_encode(null); return '';
} }
} }
} }
@@ -77,8 +77,10 @@ final class ProductMenuSearchJsonRenderer
} }
/** /**
* The overview pages of the products menu tree (/products > folder > * The overview pages of the products menu tree (/products > category >
* folder > page, see ProvideProductCatalog) with their menu path. * sub-category > page, see ProvideProductCatalog) with their menu path.
* Category and sub-category are pages since 2026-11-05 (folders before -
* both are read).
* *
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview * @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
*/ */
@@ -96,7 +98,7 @@ final class ProductMenuSearchJsonRenderer
return []; return [];
} }
$queryBuilder = $pool->getQueryBuilderForTable('pages'); $queryBuilder = $pool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages') $rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug')->from('pages')
->where( ->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
@@ -104,9 +106,16 @@ final class ProductMenuSearchJsonRenderer
) )
->orderBy('sorting') ->orderBy('sorting')
->executeQuery()->fetchAllAssociative(); ->executeQuery()->fetchAllAssociative();
// menu label = nav_title when set (same rule as ProvideProductCatalog)
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
}; };
$categories = array_column($level([$root], [254]), null, 'uid'); $categories = array_column($level([$root], [1, 254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [254]), null, 'uid'); $groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid');
$overviews = []; $overviews = [];
foreach ($level(array_keys($groups), [1]) as $page) { foreach ($level(array_keys($groups), [1]) as $page) {
$group = $groups[(int)$page['pid']]; $group = $groups[(int)$page['pid']];
@@ -258,6 +258,11 @@ class ProductShowJsonRenderer
'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')), 'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')),
]; ];
// "Why choose <product>?" cards (contract 0036-FE): null while the
// product has no feature records - the front end then keeps parsing
// the `applications` HTML above.
$data['features'] = $this->featuresBand($uid, (string)$data['applications'], (string)$data['title']);
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit // schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
// @graph string. The frontend injects it as <script type="application/ld+json">. // @graph string. The frontend injects it as <script type="application/ld+json">.
// Supersedes the manual `structureddata` field above (kept for backwards // Supersedes the manual `structureddata` field above (kept for backwards
@@ -267,6 +272,67 @@ class ProductShowJsonRenderer
return $data; return $data;
} }
/**
* The "Why choose" card band: editor-maintained tx_vitec_product_feature
* records in sorting order, icons resolved to urls. The heading is the
* first <h3> of the applications HTML, falling back to the generic
* "Why choose <title>?".
*
* @return array{heading:string,items:array<int,array<string,mixed>>}|null
*/
protected function featuresBand(int $productUid, string $applicationsHtml, string $productTitle): ?array
{
try {
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$rows = $pool->getConnectionForTable('tx_vitec_product_feature')->fetchAllAssociative(
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$productUid]
);
if ($rows === []) {
return null;
}
$icons = [];
foreach ($pool->getConnectionForTable('sys_file_reference')->fetchAllAssociative(
"SELECT r.uid_foreign, r.alternative, f.identifier FROM sys_file_reference r
JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature'
AND r.fieldname = 'icon'
AND r.uid_foreign IN (" . implode(',', array_map('intval', array_column($rows, 'uid'))) . ')
ORDER BY r.uid_foreign, r.sorting_foreign'
) as $iconRow) {
$icons[(int)$iconRow['uid_foreign']] ??= [
'url' => '/fileadmin' . (string)$iconRow['identifier'],
'alternative' => trim((string)($iconRow['alternative'] ?? '')),
];
}
$heading = '';
if (preg_match('#<h3[^>]*>(.*?)</h3>#is', $applicationsHtml, $m)) {
$heading = trim(strip_tags($m[1]));
}
if ($heading === '') {
$heading = 'Why choose ' . $productTitle . '?';
}
$items = [];
foreach ($rows as $row) {
$icon = $icons[(int)$row['uid']] ?? null;
if ($icon !== null && $icon['alternative'] === '') {
$icon['alternative'] = (string)$row['title'];
}
$items[] = [
'title' => (string)$row['title'],
'text' => trim((string)($row['cardtext'] ?? '')),
'icon' => $icon,
];
}
return ['heading' => $heading, 'items' => $items];
} catch (\Throwable) {
return null;
}
}
/** /**
* Build the product's structured-data @graph from the already-serialized * Build the product's structured-data @graph from the already-serialized
* product array. Exception-safe: returns '' on any failure. * product array. Exception-safe: returns '' on any failure.
@@ -165,9 +165,24 @@ class UsecaseListJsonRenderer
$rows = $ordered; $rows = $ordered;
} }
// Testimonial bands on sub-market/solution pages shipped ALL 70
// stories (226 kB per page, the same quotes on ~90 pages - Seobility
// 2026-11-10: 34 pages > 500 kB, 1,846 repeated text blocks). Only
// stories with a maintained quote are testimonials; the current
// market's stories come first, capped at twelve. Editor-picked
// lists stay untouched.
if ($layout === 'testimonials' && $selectedUids === []) {
$rows = $this->testimonialRows($rows);
}
$usecases = array_map( $usecases = array_map(
static function (array $u) use ($serializer, $detailBase): array { static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u); $item = $serializer->serializeListItem($u);
// The list cards read url/srcset/alternative only; the card
// crop variants cost about 1 kB per story (0102-FE, F8).
if (is_array($item['cardImage'] ?? null)) {
unset($item['cardImage']['crops']);
}
// A story with its own detail page wins; only the others // A story with its own detail page wins; only the others
// fall back to the Single PID plus the slug, so nothing // fall back to the Single PID plus the slug, so nothing
// changes for the stories that have no page assigned. // changes for the stories that have no page assigned.
@@ -210,6 +225,80 @@ class UsecaseListJsonRenderer
* Callers here build paths by concatenation, so the empty string stays the * Callers here build paths by concatenation, so the empty string stays the
* "no link" value; LinkResolver's null is coalesced away. * "no link" value; LinkResolver's null is coalesced away.
*/ */
/**
* Stories for a testimonial band: quoted ones only, current market first,
* at most twelve. Falls back to the first twelve rows when no story has
* a quote yet.
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function testimonialRows(array $rows): array
{
$quoted = array_values(array_filter(
$rows,
static fn (array $r): bool => trim((string)($r['quote'] ?? '')) !== ''
));
if ($quoted === []) {
return array_slice($rows, 0, 12);
}
try {
$marketUids = $this->currentMarketUids();
if ($marketUids !== []) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_usecase_market_mm');
$inMarket = array_map('intval', $qb
->select('uid_local')
->from('tx_vitec_usecase_market_mm')
->where($qb->expr()->in('uid_foreign', $qb->createNamedParameter($marketUids, \Doctrine\DBAL\ArrayParameterType::INTEGER)))
->executeQuery()
->fetchFirstColumn());
$matching = array_values(array_filter($quoted, static fn (array $r): bool => in_array((int)$r['uid'], $inMarket, true)));
if (count($matching) >= 3) {
$others = array_values(array_filter($quoted, static fn (array $r): bool => !in_array((int)$r['uid'], $inMarket, true)));
$quoted = array_merge($matching, $others);
}
}
} catch (\Throwable $e) {
// keep the quoted stories in default order
}
return array_slice($quoted, 0, 12);
}
/**
* Market records whose detail page lies on the current rootline.
*
* @return list<int>
*/
private function currentMarketUids(): array
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$pageInfo = $request?->getAttribute('frontend.page.information');
if ($pageInfo === null) {
return [];
}
$pageUids = array_values(array_filter(array_map(
static fn (array $p): int => (int)($p['uid'] ?? 0),
$pageInfo->getRootLine()
)));
if ($pageUids === []) {
return [];
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_market');
return array_map('intval', $qb
->select('uid')
->from('tx_vitec_domain_model_market')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->in('detail_page', $qb->createNamedParameter($pageUids, \Doctrine\DBAL\ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn());
}
private function resolvePageUrl(int $pageUid): string private function resolvePageUrl(int $pageUid): string
{ {
return LinkResolver::pageUrl($pageUid) ?? ''; return LinkResolver::pageUrl($pageUid) ?? '';
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Shared markup for the three SSR dashboard tiles, in the look of the
* "VITEC SSR" cockpit module (dark VITEC navy, orange accents).
* The dashboard inserts widget HTML without running scripts, so rings
* and bars are drawn here as plain SVG/HTML. Styles:
* Resources/Public/Css/dashboard-ssr.css (scoped to .vc-widget).
*/
final class CockpitHtml
{
public const CSS = 'EXT:vitec/Resources/Public/Css/dashboard-ssr.css';
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES);
}
/**
* Tile wrapper; $info becomes the round "?" button top right whose
* tooltip explains the tile (CSS only: hover or keyboard focus).
*/
public static function wrap(string $inner, string $info = '', string $id = ''): string
{
$button = '';
if ($info !== '') {
$tipId = 'vcw-tip-' . ($id !== '' ? $id : substr(md5($info), 0, 8));
$button = '<span class="vcw-info"><button type="button" class="vcw-info-btn" aria-label="About this tile"'
. ' aria-describedby="' . $tipId . '">?</button><span class="vcw-tip" role="tooltip" id="' . $tipId . '">'
. self::e($info) . '</span></span>';
}
return '<div class="vc-widget">' . $button . $inner . '</div>';
}
public static function pill(string $tone, string $text, string $title = ''): string
{
return '<span class="vcw-pill is-' . self::e($tone) . '"'
. ($title !== '' ? ' title="' . self::e($title) . '"' : '') . '>' . self::e($text) . '</span>';
}
/**
* Ring gauge, 0..1, with a short text in the middle.
*/
public static function ring(float $fraction, string $tone, string $center, int $size = 76): string
{
$c = 2 * M_PI * 40;
$offset = $c * (1 - max(0.0, min(1.0, $fraction)));
return '<div class="vcw-ring" style="width:' . $size . 'px;height:' . $size . 'px">'
. '<svg viewBox="0 0 96 96" width="' . $size . '" height="' . $size . '" aria-hidden="true">'
. '<circle class="vcw-track" cx="48" cy="48" r="40"/>'
. '<circle class="vcw-val is-' . self::e($tone) . '" cx="48" cy="48" r="40" stroke-dasharray="'
. round($c, 2) . '" stroke-dashoffset="' . round($offset, 2) . '"/></svg>'
. '<span class="vcw-ring-c">' . self::e($center) . '</span></div>';
}
/**
* Same labels and tones as the purge timeline in the module.
*
* @return array{0: string, 1: string, 2: string} [label, tone, user]
*/
public static function trigger(string $source): array
{
$who = '';
if (str_contains($source, ':')) {
[$source, $who] = explode(':', $source, 2);
}
return match ($source) {
'module' => ['manual', 'orange', $who],
'contextmenu' => ['context menu', 'navy', $who],
'editor-save', 'editor-command' => ['auto-purge', 'ok', $who],
'switch-on' => ['switch-on', 'warn', $who],
default => [$source, 'sky', $who],
};
}
/**
* Availability strip of the last 24 hours, 96 windows of 15 minutes,
* from the health log (cron vitec:ssr-health every 5 minutes).
*
* @return array{html: string, uptime: ?float, slow: int, down: int, samples: int}
*/
public static function strip(ConnectionPool $pool): array
{
$slot = 900;
$end = intdiv(time(), $slot) * $slot + $slot;
$windows = array_fill(0, 96, null);
$ok = 0;
$n = 0;
try {
$rows = $pool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
'SELECT crdate, ok, response_ms FROM tx_vitec_ssrhealthlog WHERE crdate > ?',
[$end - 96 * $slot]
)->fetchAllAssociative();
} catch (\Throwable) {
$rows = [];
}
foreach ($rows as $r) {
$i = 95 - intdiv($end - 1 - (int)$r['crdate'], $slot);
if ($i < 0 || $i > 95) {
continue;
}
$n++;
$isOk = (int)$r['ok'] === 1;
$ok += $isOk ? 1 : 0;
$state = !$isOk ? 2 : ((int)$r['response_ms'] > 400 ? 1 : 0);
if ($windows[$i] === null || $state > $windows[$i]['s']) {
$windows[$i] = ['s' => $state, 'ms' => (int)$r['response_ms']];
}
}
$slow = 0;
$down = 0;
$html = '<div class="vcw-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours">';
foreach ($windows as $i => $w) {
$from = $end - (96 - $i) * $slot;
$label = date('H:i', $from) . '–' . date('H:i', $from + $slot) . ' · ';
if ($w === null) {
$html .= '<i class="is-none" title="' . self::e($label . 'no sample') . '"></i>';
continue;
}
$cls = ['', 'is-slow', 'is-down'][$w['s']];
$slow += $w['s'] === 1 ? 1 : 0;
$down += $w['s'] === 2 ? 1 : 0;
$text = $w['s'] === 2 ? 'down' : ($w['s'] === 1 ? 'slow, ' : 'ok, ') . ($w['s'] === 2 ? '' : $w['ms'] . ' ms');
$html .= '<i class="' . $cls . '" title="' . self::e($label . $text) . '"></i>';
}
$html .= '</div><div class="vcw-axis"><span>' . date('H:i', $end - 96 * $slot) . '</span><span>'
. date('H:i', $end - 48 * $slot) . '</span><span>now</span></div>';
return [
'html' => $html,
'uptime' => $n > 0 ? round($ok / $n * 100, 1) : null,
'slow' => $slow,
'down' => $down,
'samples' => $n,
];
}
public static function moduleLink(string $label = 'Open the SSR cockpit'): string
{
try {
$uri = (string)GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute('web_vitecssr');
} catch (\Throwable) {
return '';
}
return '<a class="vcw-link" href="' . self::e($uri) . '">' . self::e($label) . ' &rarr;</a>';
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: SSR error counters and cache hit rate.
*
* Source: the cumulative /__stats counters of the SSR node, sampled every
* 5 minutes by vitec:ssr-health into tx_vitec_ssrhealthlog (stat_* columns,
* -1 = no data for that sample). The counters reset whenever the node
* restarts (deploy!), so "totals" are "since node start" and the 24h error
* figures are sums of positive deltas between consecutive samples - a
* reset simply contributes nothing.
*/
final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$latest = $this->latestStatsRow();
if ($latest === null) {
return $h::wrap('<p class="vcw-dim">No /__stats samples yet. They arrive with the next '
. '<code>vitec:ssr-health</code> cron run (every 5 minutes) once the node release '
. 'provides <code>/__stats</code>.</p>', 'Cache hit rate and error counters of the SSR node, '
. 'filled by the health cron every 5 minutes.', 'errors');
}
$hit = (int)$latest['stat_hit'];
$stale = (int)$latest['stat_stale'];
$miss = (int)$latest['stat_miss'];
$served = $hit + $miss + $stale;
$rate = $served > 0 ? ($hit + $stale) / $served : null;
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
$down24h = $this->downSamples24h();
// Hit rate ring + split bar hit / stale / miss
$pct = static fn(int $v): string => $served > 0 ? round($v / $served * 100, 1) . '%' : '0%';
$html = '<div class="vcw-gauge vcw-gauge-wide">'
. $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84)
. '<div class="vcw-grow"><div class="vcw-label">Cache hit rate</div>'
. '<div class="vcw-sub">since the node started · ' . number_format($served, 0, '.', ',') . ' requests</div>'
. '<div class="vcw-split-bar" role="img" aria-label="Share of requests by cache result">'
. '<i class="is-navy" style="width:' . $pct($hit) . '" title="served from cache: ' . $hit . '"></i>'
. '<i class="is-sky" style="width:' . $pct($stale) . '" title="stale, refreshed in background: ' . $stale . '"></i>'
. '<i class="is-warn" style="width:' . $pct($miss) . '" title="rendered fresh: ' . $miss . '"></i></div>'
. '<div class="vcw-legend"><span><i class="is-navy"></i>cache ' . $hit . '</span>'
. '<span><i class="is-sky"></i>stale ' . $stale . '</span><span><i class="is-warn"></i>fresh ' . $miss . '</span></div>'
. '</div></div>';
$tile = static fn(string $label, string $value, string $sub, string $tone): string =>
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
. $value . '</div><div class="vcw-sub">' . $sub . '</div></div>';
$newCell = static fn(?int $new): array => $new === null
? ['n/a', '']
: ['+' . $new, $new > 0 ? 'vcw-bad' : 'vcw-ok'];
[$renderVal, $renderTone] = $newCell($newRender24h);
[$backendVal, $backendTone] = $newCell($newBackend24h);
$html .= '<div class="vcw-tiles">'
. $tile('Render errors', $renderVal, 'last 24 h · ' . (int)$latest['stat_render_errors'] . ' since node start', $renderTone)
. $tile('Backend errors', $backendVal, 'last 24 h · ' . (int)$latest['stat_backend_errors'] . ' since node start', $backendTone)
. $tile('Down samples', (string)$down24h['count'], $down24h['count'] > 0 ? 'last ' . $h::e($down24h['last']) : 'last 24 h', $down24h['count'] > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Sample age', $h::e($this->age((int)$latest['crdate'])), 'cron every 5 min', '')
. '</div>';
$html .= '<p class="vcw-note">Totals count since the node last started; a deploy resets them. '
. 'Error details: <code>journalctl -u vitec-ssr</code> on the VPS.</p>';
return $h::wrap($html, 'How well the SSR cache works and whether the node has problems. The ring is the '
. 'share of requests answered from the cache since the node last started; the bar splits them into '
. 'served from cache, stale and refreshed in the background, and rendered fresh. Render errors are pages '
. 'the node could not render, backend errors are failed requests to TYPO3. Down samples are failed '
. 'health checks in the last 24 hours.', 'errors');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function latestStatsRow(): ?array
{
try {
$row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors'
. ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1'
)->fetchAssociative();
return $row === false ? null : $row;
} catch (\Throwable) {
return null; // Column missing before database:updateschema - render the empty state.
}
}
/**
* New errors in the last 24 h = sum of positive deltas between
* consecutive stats samples. Rows with -1 (node down / no stats) are
* skipped; counter resets produce a negative step, clamped to 0.
*
* @return array{0: ?int, 1: ?int} [renderErrors, backendErrors]
*/
private function errorDeltas24h(): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog'
. ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC'
)->fetchAllAssociative();
} catch (\Throwable) {
return [null, null];
}
if (count($rows) < 2) {
return [null, null];
}
$render = 0;
$backend = 0;
$prev = null;
foreach ($rows as $r) {
if ($prev !== null) {
$render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']);
$backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']);
}
$prev = $r;
}
return [$render, $backend];
}
/**
* @return array{count: int, last: string}
*/
private function downSamples24h(): array
{
try {
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$count = (int)$conn->executeQuery(
'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0'
)->fetchOne();
$last = $count > 0
? date('d.m. H:i', (int)$conn->executeQuery(
'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0'
)->fetchOne())
: '-';
return ['count' => $count, 'last' => $last];
} catch (\Throwable) {
return ['count' => 0, 'last' => '-'];
}
}
private function age(int $crdate): string
{
$minutes = (int)floor((time() - $crdate) / 60);
return $minutes < 1 ? 'just now' : $minutes . ' min';
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: purge and health statistics from the two protocol
* tables. History depth = what vitec:ssr-health sampling (cron, every
* 5 minutes) and the purge log hold (14 days). Cockpit look (CockpitHtml):
* availability strip of the last 24 h plus number tiles.
*/
final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$now = time();
$d1 = $now - 86400;
$d7 = $now - 7 * 86400;
$purges24h = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d1);
$purges7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7);
$failed7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7 . ' AND success = 0');
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
// Browser-side errors reported via POST /api/vitec/client-error,
// without smoke/curl test reports (0114-FE).
[$clientErrors24h, $clientErrors7d] = $this->realClientErrors($d1, $d7);
$strip = $h::strip($this->connectionPool);
$uptime = $strip['uptime'];
$uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad'));
$html = '<div class="vcw-strip-h"><div><div class="vcw-label">Availability · 24 h</div>'
. '<div class="vcw-num ' . $uptimeTone . '">' . ($uptime === null ? '–' : number_format($uptime, 1)) . '<small>%</small></div></div>'
. '<div class="vcw-sub vcw-right">' . $strip['samples'] . ' samples<br>' . $strip['slow'] . ' slow · '
. $strip['down'] . ' down</div></div>'
. $strip['html'];
$tile = static fn(string $label, string $value, string $sub = '', string $tone = ''): string =>
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
. $value . '</div>' . ($sub !== '' ? '<div class="vcw-sub">' . $sub . '</div>' : '') . '</div>';
$html .= '<div class="vcw-tiles">'
. $tile('Purges', $purges24h . '<small>/ ' . $purges7d . '</small>', '24 h / 7 days')
. $tile('Full purges', (string)$full7d, 'last 7 days', $full7d > 0 ? 'vcw-warn' : '')
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Avg. response', $avgMs !== null ? $avgMs . '<small>ms</small>' : '–', 'last 24 h')
. $tile('Max. cache', $maxCache !== null ? $maxCache . '<small>MB</small>' : '–', 'last 7 days')
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
. '</div>';
if ($strip['samples'] === 0) {
$html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
}
$html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>';
return $h::wrap($html, 'Availability of the SSR node over the last 24 hours, one bar per 15 minutes: '
. 'green ok, yellow slower than 400 ms, red down, grey no sample. Below are counts from the purge and '
. 'health logs: purges, full purges and failed purges, average response time, the largest cache size '
. 'and the browser errors reported by the frontend.', 'stats');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
/**
* @return array{0: int, 1: int} real client error reports in 24 h / 7 days
*/
private function realClientErrors(int $d1, int $d7): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$d7]
)->fetchAllAssociative();
} catch (\Throwable) {
return [0, 0];
}
$day = 0;
$week = 0;
foreach ($rows as $r) {
if (ClientErrorClassifier::isTest((string)$r['message'], (string)$r['user_agent'], (string)$r['release'])) {
continue;
}
$week++;
$day += (int)$r['crdate'] > $d1 ? 1 : 0;
}
return [$day, $week];
}
private function count(string $table, string $where): int
{
try {
return (int)$this->connectionPool->getConnectionForTable($table)
->executeQuery('SELECT COUNT(*) FROM ' . $table . ' WHERE ' . $where)->fetchOne();
} catch (\Throwable) {
return 0;
}
}
private function scalar(string $sql): ?string
{
try {
$v = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery($sql)->fetchOne();
return $v === null || $v === false ? null : (string)$v;
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use Evomedien\Vitec\Service\SsrPurgeService;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: live state of the SSR node on the Solr VPS plus the
* delivery switch - green/red at a glance after backend login. Same look
* as the "VITEC SSR" cockpit module (CockpitHtml).
*/
final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$flagOn = file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag');
$health = $this->fetchHealth();
$lastPurge = $this->lastPurgeRow();
$limit = $this->purgeService->getCacheLimitMb();
// Head: pulse + state
if ($health === null) {
$head = '<span class="vcw-pulse is-down" aria-hidden="true"></span><div>'
. '<div class="vcw-title">SSR <span class="vcw-bad">not reachable</span></div>'
. '<div class="vcw-sub">The cron falls back to the SPA after '
. $this->purgeService->getFailoverAfter() . ' failed checks in a row</div></div>';
} else {
$ok = (bool)($health['ok'] ?? false);
$head = '<span class="vcw-pulse' . ($ok ? '' : ' is-warn') . '" aria-hidden="true"></span><div>'
. '<div class="vcw-title">SSR <span class="' . ($ok ? 'vcw-ok">online' : 'vcw-warn">reports errors') . '</span></div>'
. '<div class="vcw-sub">' . ($flagOn ? 'Pages are rendered on the VPS' : 'Delivery is off: TYPO3 serves the SPA')
. ' · ' . (int)$health['responseMs'] . ' ms</div></div>';
}
$html = '<div class="vcw-head">' . $head . '</div>';
// Cache ring + facts
$html .= '<div class="vcw-split">';
if ($health !== null) {
$mb = (int)($health['cacheMB'] ?? 0);
$pages = (int)($health['cachedPages'] ?? 0);
$frac = $limit > 0 ? $mb / $limit : 0.0;
$html .= '<div class="vcw-gauge">' . $h::ring($frac, 'sky', $limit > 0 ? round($frac * 100) . ' %' : $mb . ' MB')
. '<div><div class="vcw-label">Cache</div><div class="vcw-num">' . number_format($pages, 0, '.', ',')
. '<small>pages</small></div><div class="vcw-sub">' . $mb . ($limit > 0 ? ' / ' . $limit : '') . ' MB</div></div></div>';
}
$html .= '<dl class="vcw-facts">'
. '<div><dt>Delivery</dt><dd>' . ($flagOn ? $h::pill('ok', 'ON') : $h::pill('muted', 'OFF')) . '</dd></div>';
if ($health !== null) {
$html .= '<div><dt>Release</dt><dd><code title="built ' . $h::e($this->fmtDate((string)($health['release']['builtAt'] ?? '')))
. '">' . $h::e((string)($health['release']['release'] ?? '?')) . '</code></dd></div>'
. '<div><dt>Commit</dt><dd><code>' . $h::e((string)($health['release']['commit'] ?? '?')) . '</code></dd></div>';
}
$html .= '<div><dt>Webhook</dt><dd>' . ($this->purgeService->isConfigured()
? $h::pill('ok', 'configured')
: $h::pill('warn', 'not configured', 'var/vitec-ssr-purge.env is missing or incomplete')) . '</dd></div>'
. '</dl></div>';
// Last purge
if ($lastPurge !== null) {
[$label, $tone, $who] = $h::trigger((string)$lastPurge['trigger_source']);
$paths = trim((string)$lastPurge['paths']);
$html .= '<div class="vcw-last"><span class="vcw-label">Last purge</span>'
. '<span class="vcw-time">' . date('d.m. H:i', (int)$lastPurge['crdate']) . '</span>'
. $h::pill($tone, $label) . ($who !== '' ? ' <span class="vcw-dim">' . $h::e($who) . '</span>' : '')
. ' ' . ((int)$lastPurge['success'] ? $h::pill('ok', 'ok') : $h::pill('bad', 'failed'))
. '<span class="vcw-path" title="' . $h::e($paths) . '">'
. ($paths === '*' ? 'all pages (*)' : $h::e(mb_strimwidth($paths, 0, 70, '…'))) . '</span></div>';
}
$html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
return $h::wrap($html, 'Live state of the SSR node on the VPS: whether it answers and how fast, '
. 'how full its page cache is against the limit, whether SSR delivery is switched on, which frontend '
. 'release runs, and the most recent cache purge. The link opens the cockpit with charts, protocol '
. 'and the purge and warm buttons.', 'status');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function fetchHealth(): ?array
{
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
return null;
}
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$data = json_decode((string)$response->getBody(), true);
if (!is_array($data)) {
return null;
}
$data['responseMs'] = (int)round((microtime(true) - $start) * 1000);
return $data;
} catch (\Throwable) {
return null;
}
}
private function lastPurgeRow(): ?array
{
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_ssrpurgelog');
$row = $qb->select('*')->from('tx_vitec_ssrpurgelog')
->orderBy('uid', 'DESC')->setMaxResults(1)
->executeQuery()->fetchAssociative();
return $row ?: null;
} catch (\Throwable) {
return null;
}
}
private function fmtDate(string $iso): string
{
$ts = strtotime($iso);
return $ts ? date('Y-m-d H:i', $ts) : '?';
}
}
@@ -27,4 +27,16 @@ return [
'path' => '/vitec/product/field/save', 'path' => '/vitec/product/field/save',
'target' => ProductTextImportController::class . '::fieldSave', 'target' => ProductTextImportController::class . '::fieldSave',
], ],
// Context menu "Purge from SSR cache" (SsrPurgeItemProvider +
// Resources/Public/Javascript/ssr-context-menu.js).
'vitec_ssr_purge_page' => [
'path' => '/vitec/ssr/purge-page',
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgePage',
],
// Live status of the VITEC SSR cockpit
// (Resources/Public/Javascript/ssr-cockpit.js polls it).
'vitec_ssr_health' => [
'path' => '/vitec/ssr/health',
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::healthJson',
],
]; ];
@@ -127,4 +127,30 @@ return [
], ],
], ],
], ],
'web_vitecssr' => [
'parent' => 'web',
'position' => ['after' => 'web_vitecimport'],
'access' => 'user',
'workspaces' => 'live',
'path' => '/module/web/vitec-ssr',
'labels' => [
'title' => 'VITEC SSR',
'shortDescription' => 'SSR delivery: VPS status, manual purge, purge/health protocol',
],
'extensionName' => 'Vitec',
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
'routes' => [
'_default' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::indexAction',
],
'purge' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgeAction',
'methods' => ['POST'],
],
'warm' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::warmAction',
'methods' => ['POST'],
],
],
],
]; ];
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Page Content Check</sheetTitle>
<type>array</type>
<el>
<settings.scope>
<label>Page tree to audit</label>
<description>Markets = the 11 markets and their sub-markets below /markets. Solutions = the 8 solution groups and their sub-solutions below /solutions.</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>Markets and sub-markets</label>
<value>markets</value>
</numIndex>
<numIndex index="1">
<label>Solutions and sub-solutions</label>
<value>solutions</value>
</numIndex>
</items>
<default>markets</default>
</config>
</settings.scope>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>
@@ -9,7 +9,7 @@ declare(strict_types=1);
* JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js'). * JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js').
*/ */
return [ return [
'dependencies' => ['backend', 'core'], 'dependencies' => ['backend', 'core', 'dashboard'],
'imports' => [ 'imports' => [
'@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/', '@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/',
], ],
@@ -46,5 +46,50 @@ return [
'typo3/cms-frontend/site', 'typo3/cms-frontend/site',
], ],
], ],
// Self-healing sync of the SSR routing flag file against the
// Site Set switch vitec.ssr.enabled (no core event fires on
// settings save, hence per-request sync; cheap file_exists when
// nothing changed). Also registered for the backend below.
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
// Client error reports from the React frontend:
// POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204
// (0072-FE section 4 / 0077-BE). Answers before site resolution,
// no session, rate-limited per IP, 8 KB body limit. Placed after
// the /_json strip so the prefixed form reaches it as well.
'vitec/client-error' => [
'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
'vitec/json-prefix-rewrite',
],
'before' => [
'typo3/cms-frontend/site',
'vitec/form-submission',
],
],
// Outer response filter: Cache-Control: no-cache + ETag/304 for
// /_json (tickets 0011-FE / 0055-BE).
'vitec/json-cache-headers' => [
'target' => \Evomedien\Vitec\Middleware\JsonCacheHeaders::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'vitec/json-prefix-rewrite',
],
],
],
'backend' => [
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
], ],
]; ];
@@ -22,3 +22,50 @@ services:
# the final page title (seo.title) into meta.title as well. # the final page title (seo.title) into meta.title as well.
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface: FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
alias: Evomedien\Vitec\Seo\VitecMetaHandler alias: Evomedien\Vitec\Seo\VitecMetaHandler
# The DataHandler hook fetches this via GeneralUtility::makeInstance,
# which resolves constructor arguments only for container-public services.
Evomedien\Vitec\Service\SsrPurgeService:
public: true
# Fetched via GeneralUtility::makeInstance from the USER func
# ProductCheckJsonRenderer (TypoScript userFunc, no DI there).
Evomedien\Vitec\Service\ProductCheckService:
public: true
Evomedien\Vitec\Service\PageCheckService:
public: true
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
# injected by the dashboard compiler pass via the tag).
Evomedien\Vitec\Widgets\SsrStatusWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrStatus
groupNames: systemInfo
title: 'VITEC: SSR / VPS status'
description: 'Live state of the SSR server (release, cache, reachability) and the delivery switch'
iconIdentifier: 'content-widget-text'
height: 'medium'
width: 'medium'
Evomedien\Vitec\Widgets\SsrStatsWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrStats
groupNames: systemInfo
title: 'VITEC: SSR statistics'
description: 'Purge protocol and availability of the SSR server (14 days of history)'
iconIdentifier: 'content-widget-number'
height: 'medium'
width: 'medium'
Evomedien\Vitec\Widgets\SsrErrorsWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrErrors
groupNames: systemInfo
title: 'VITEC: SSR errors & hit rate'
description: 'Render/backend error counters and cache hit rate of the SSR node (/__stats, sampled every 5 minutes)'
iconIdentifier: 'content-widget-list'
height: 'medium'
width: 'medium'
@@ -11,3 +11,7 @@ dependencies:
# solr must load before us so our plugin.tx_solr overrides (statistics, # solr must load before us so our plugin.tx_solr overrides (statistics,
# highlighting, content extraction) survive - set order follows dependencies # highlighting, content extraction) survive - set order follows dependencies
- apache-solr-for-typo3/solr - apache-solr-for-typo3/solr
# sitemap sets must load before us so our plugin.tx_seo sitemap overrides
# (news pids, products/stories record sitemaps) survive
- typo3/seo-sitemap
- georgringer/news-sitemap
@@ -10,18 +10,6 @@ categories:
description: 'Footer & meta menu page selection' description: 'Footer & meta menu page selection'
settings: settings:
my.vitec.setting:
label: 'My example setting'
category: VITEC
type: string
default: ''
my.seoRelevantSetting:
label: 'My SEO relevant setting'
category: seo
type: int
default: 5
vitec.debugMode: vitec.debugMode:
label: 'Enable Debug Mode' label: 'Enable Debug Mode'
description: 'Enable debug mode for VITEC extensions' description: 'Enable debug mode for VITEC extensions'
@@ -132,3 +120,24 @@ settings:
category: seo category: seo
type: string type: string
default: '' default: ''
vitec.ssr.enabled:
label: 'SSR delivery (serve pages from the SSR node)'
description: 'ON: page requests are proxied by .htaccess to the SSR server (React Router SSR on the Solr VPS) - the flag file var/vitec-ssr-on.flag is kept in sync automatically within seconds. OFF: pages are served as today (SPA shell from _frontend/). JSON under /_json, /typo3, /api etc. are never affected. Safe rollback switch.'
category: VITEC
type: bool
default: false
vitec.ssr.autoPurge:
label: 'SSR auto-purge on editor changes'
description: 'ON: saving pages, content, news or VITEC records automatically evicts the pages that show them from the SSR page cache - a product only from its detail page and the lists that contain it. Menu-relevant changes (titles, URLs, visibility), the newest news, events and the megamenu evict everything. OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.'
category: VITEC
type: bool
default: true
vitec.ssr.alertEmails:
label: 'SSR alert mail recipients'
description: 'E-mail addresses that get the alert mails of the SSR health check: node down, node back, automatic fallback to the SPA. One address per entry; invalid addresses are skipped. Empty: the addresses from SSR_ALERT_EMAIL in var/vitec-ssr-purge.env are used.'
category: VITEC
type: stringlist
default: []
@@ -77,6 +77,9 @@ tt_content {
fields { fields {
productfeature = USER productfeature = USER
productfeature.userFunc = Evomedien\Vitec\UserFunc\ProductFeatureJsonRenderer->render productfeature.userFunc = Evomedien\Vitec\UserFunc\ProductFeatureJsonRenderer->render
# No product selected/visible -> real JSON null, not
# the string "null" (ticket 0068-FE).
productfeature.ifEmptyReturnNull = 1
} }
} }
} }
@@ -97,6 +100,35 @@ tt_content {
} }
} }
# Product Content Check - the product audit table as data (page
# /products/product-check). Always USER_INT, see ProductCheckJsonRenderer.
vitec_productcheck < lib.contentElementWithHeader
vitec_productcheck {
fields {
content {
fields {
productcheck = USER
productcheck.userFunc = Evomedien\Vitec\UserFunc\ProductCheckJsonRenderer->render
}
}
}
}
# Page Content Check - the market / solution page audit as data (pages
# /markets/market-check, /solutions/solution-check). Always USER_INT, see PageCheckJsonRenderer.
vitec_pagecheck < lib.contentElementWithHeader
vitec_pagecheck {
fields {
content {
fields {
pagecheck = USER
pagecheck.userFunc = Evomedien\Vitec\UserFunc\PageCheckJsonRenderer->render
}
}
}
}
# EXT:solr search results as JSON - the search endpoint for the React frontend # EXT:solr search results as JSON - the search endpoint for the React frontend
solr_pi_results < lib.contentElementWithHeader solr_pi_results < lib.contentElementWithHeader
solr_pi_results { solr_pi_results {
@@ -331,6 +363,17 @@ tt_content {
page.10.fields.jsonLd = USER page.10.fields.jsonLd = USER
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
# =============================================================================
# No browser caching of the page JSON (2026-11-05). EXT:headless sets
# page.config.sendCacheHeaders = 1, and TYPO3 14 caches a page for ONE YEAR
# by default (CacheLifetimeCalculator::defaultCacheTimeout) - every /_json/
# response went out with "Cache-Control: max-age=~31536000", so browsers
# kept an old menu/page for up to a year (found when the clickable product
# categories did not show up). The server-side page cache is unaffected;
# without the header, .htaccess sends application/json "access plus 0 seconds".
# =============================================================================
page.config.sendCacheHeaders = 0
############################################################ ############################################################
# Frontend resolution for the record links from the link # Frontend resolution for the record links from the link
# browser (page.tsconfig TCEMAIN.linkHandler): # browser (page.tsconfig TCEMAIN.linkHandler):
@@ -1006,3 +1049,59 @@ plugin.tx_solr.suggest {
# recip(age) = 10 when new, ~5 after one year, ~0.7 after 13 years. # recip(age) = 10 when new, ~5 after one year, ~0.7 after 13 years.
# The gate keeps products/pages/stories out of the decay entirely. # The gate keeps products/pages/stories out of the decay entirely.
plugin.tx_solr.search.query.boostFunction = if(termfreq(type,'tx_news_domain_model_news'),recip(ms(NOW,created),3.16e-11,10,1),10) plugin.tx_solr.search.query.boostFunction = if(termfreq(type,'tx_news_domain_model_news'),recip(ms(NOW,created),3.16e-11,10,1),10)
# ======================================================================
# XML-Sitemap (SEO-Audit 2026-11-09)
#
# - news: the georgringer/news-sitemap set ships pid=0 / pageId=0, which
# rendered an EMPTY news sitemap. Records live in folders 11 (Blog) and
# 442 (Press); the detail route sits on page 21 (/news/article/<slug>).
# - products: record sitemap for /product/<slug> detail pages (folder 30).
# - stories: record sitemap for success stories (folder 14) with the
# PUBLIC /success-stories/<slug> URLs (custom provider strips the
# internal /story/ segment).
#
# Requires vitecset to load AFTER typo3/seo-sitemap + georgringer/
# news-sitemap - see the dependencies in this set's config.yaml.
# ======================================================================
plugin.tx_seo.config.xmlSitemap.sitemaps {
news.config {
pid = 11,442
url.pageId = 21
}
products {
provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider
config {
table = tx_vitec_domain_model_product
sortField = title
lastModifiedField = tstamp
pid = 30
additionalWhere = {#hideonwebsite} = 0
url {
pageId = 10
fieldToParameterMap {
uid = tx_vitec_productshow[product]
}
}
}
}
stories {
provider = Evomedien\Vitec\Seo\StoriesXmlSitemapDataProvider
config {
table = tx_vitec_domain_model_usecase
sortField = title
lastModifiedField = tstamp
pid = 14
additionalWhere = {#hideonwebsite} = 0 AND {#no_index} = 0
url {
pageId = 26
fieldToParameterMap {
uid = tx_vitec_usecaseshow[usecase]
}
}
}
}
}
@@ -27,6 +27,20 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
$registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist');
// Product content audit as page content (2026-11-12): no FlexForm, the
// element has nothing to configure - data comes from ProductCheckService.
ExtensionUtility::registerPlugin(
'Vitec',
'Productcheck',
'Product Content Check (audit table)',
'vitec-plugin-productlist',
'plugins',
'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.'
);
// Page content audit for the market and solution trees (2026-11-13):
// scope (markets | solutions) via FlexForm, data from PageCheckService.
$registerPluginWithFlexForm('Pagecheck', 'Page Content Check (markets / solutions audit)', 'FILE:EXT:vitec/Configuration/FlexForms/Pagecheck.xml', 'vitec-plugin-productlist');
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard'); $registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow'); $registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow'); $registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* VITEC meaning of the megamenu's data-source fields (2026-11-05, ticket
* 0046-FE): the Insights spotlight (ProvideInsightsSpotlight) reads its
* "Latest news" cards from these folders and categories.
*/
defined('TYPO3') or die();
if (isset($GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages'])) {
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages']['description']
= 'Insights: news folders for the "Latest news" cards. Empty = 11 + 442 (like the /insights/news list).';
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_categories']['description']
= 'Insights: only news in one of these categories (e.g. Blog Post, Press Release). Empty = all categories.';
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
defined('TYPO3') or die();
// Canonical story for a news article: when a success story carries the same
// text as the article, the article stays in the news list but its canonical
// link points to the story (VitecMetaHandler reads this field).
(static function (): void {
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('tx_news_domain_model_news', [
'vitec_canonical_usecase' => [
'label' => 'Canonical success story',
'description' => 'Set when a success story carries the same text as this article. The article stays in the news list; search engines count the story as the original.',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'tx_vitec_domain_model_usecase',
'foreign_table_where' => 'AND {#tx_vitec_domain_model_usecase}.{#deleted} = 0 ORDER BY tx_vitec_domain_model_usecase.title',
'items' => [['label' => '— none —', 'value' => 0]],
'default' => 0,
'size' => 1,
'maxitems' => 1,
],
],
]);
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
'tx_news_domain_model_news',
'--div--;VITEC SEO, vitec_canonical_usecase'
);
})();
@@ -23,7 +23,7 @@ return [
], ],
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions, '1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, features, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions,
--div--;Resources, resources, resources_layout, --div--;Resources, resources, resources_layout,
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata, --div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile, --div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
@@ -484,6 +484,27 @@ return [
], ],
], ],
], ],
// "Why choose <product>?" cards (contract 0036-FE, 2026-11-04): the
// structured twin of the `applications` RTE blob - one record per card
// (title, text, optional icon). The front end renders `features` when
// items exist and falls back to parsing `applications` otherwise, so
// editors may keep working in either place during the transition.
'features' => [
'exclude' => true,
'label' => 'Why-choose Features',
'description' => '"Why choose" cards of the detail page: title, text and icon per card. When at least one card exists, the front end shows these instead of parsing the Applications HTML.',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_vitec_product_feature',
'foreign_field' => 'product',
'foreign_sortby' => 'sorting',
'appearance' => [
'collapseAll' => true,
'levelLinksPosition' => 'top',
'useSortable' => true,
],
],
],
'resources' => [ 'resources' => [
'exclude' => true, 'exclude' => true,
'label' => 'Resources', 'label' => 'Resources',
@@ -0,0 +1,85 @@
<?php
/**
* "Why choose <product>?" cards of the product detail page (contract
* 0036-FE, 2026-11-04): title + short text + optional icon, rendered as the
* centered card band under the intro ("Find the right appliance for your
* application" in the Figma). Child table of tx_vitec_domain_model_product
* via the `features` inline field - edited only there (hideTable), no
* Extbase model on purpose: ProductShowJsonRenderer queries the rows
* directly. Seeded once from the `applications` HTML pairs plus the staged
* agency icons by vitec:seed-product-features; afterwards editors own the
* records (the seeder never touches a product that already has cards).
*/
return [
'ctrl' => [
'title' => 'Product Feature (Why choose)',
'label' => 'title',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'enablecolumns' => [
'disabled' => 'hidden',
],
'iconfile' => 'EXT:vitec/Resources/Public/Icons/tx_vitec_domain_model_download.gif',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => ['showitem' => 'title, cardtext, icon, hidden'],
],
'columns' => [
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.visible',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'items' => [
[
'label' => '',
'invertStateDisplay' => true,
],
],
],
],
'product' => [
'config' => [
'type' => 'passthrough',
],
],
'title' => [
'exclude' => true,
'label' => 'Card title',
'config' => [
'type' => 'input',
'size' => 40,
'eval' => 'trim',
'default' => '',
],
],
'cardtext' => [
'exclude' => true,
'label' => 'Card text',
'config' => [
'type' => 'text',
'cols' => 40,
'rows' => 3,
'eval' => 'trim',
],
],
'icon' => [
'exclude' => true,
'label' => 'Icon',
'description' => 'Feature icon (SVG). The agency set lives under fileadmin/icons/delivered/products/.',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types,svg',
],
],
],
];
@@ -1,11 +1,15 @@
# ============================================================================= # =============================================================================
# VITEC menus — Headless / JSON output # VITEC menus — Headless / JSON output
# #
# Three menus are exposed at the page root of the JSON response: # Two menus are exposed at the page root of the JSON response:
# - mainNavigation : full hierarchy from root, all visible pages
# - footerMenu : curated, configured via site setting menu.footer.pageUids # - footerMenu : curated, configured via site setting menu.footer.pageUids
# - metaMenu : curated, configured via site setting menu.meta.pageUids # - metaMenu : curated, configured via site setting menu.meta.pageUids
# #
# The main navigation is NOT part of the page JSON any more (0110-FE, 12.11.2026):
# the frontend reads it from the headless initial-data type (?type=834, field
# `navigation`) in both SSR and SPA mode. Same for `i18n` (no language switch)
# and `favicons` (static in the frontend's root.tsx) — removed at the end.
#
# Each menu uses friendsoftypo3/headless MenuProcessor which: # Each menu uses friendsoftypo3/headless MenuProcessor which:
# * resolves Shortcut pages to their target # * resolves Shortcut pages to their target
# * skips pages with "Show in menu = no" (nav_hide=1) by default # * skips pages with "Show in menu = no" (nav_hide=1) by default
@@ -13,23 +17,6 @@
# * exposes `children` for sub-levels # * exposes `children` for sub-levels
# ============================================================================= # =============================================================================
# -----------------------------------------------------------------------------
# Main navigation — full hierarchy
# -----------------------------------------------------------------------------
lib.mainNavigation = JSON
lib.mainNavigation {
dataProcessing {
10 = FriendsOfTYPO3\Headless\DataProcessing\MenuProcessor
10 {
levels = 10
expandAll = 1
includeSpacer = 0
titleField = nav_title // title
as = mainNavigation
}
}
}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Footer menu — curated by site setting (UIDs) # Footer menu — curated by site setting (UIDs)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -69,13 +56,14 @@ lib.metaMenu {
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Wire them into the page-level JSON output # Wire them into the page-level JSON output
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
page.10.fields.mainNavigation =< lib.mainNavigation page.10.fields.footerMenu =< lib.footerMenu
page.10.fields.footerMenu =< lib.footerMenu page.10.fields.metaMenu =< lib.metaMenu
page.10.fields.metaMenu =< lib.metaMenu
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Favicon / app-icon set (site-wide) — see FaviconsJsonRenderer # Not delivered any more (0110-FE): nobody reads these in the frontend.
# Icon files: public/fileadmin/icons/ # mainNavigation — was the full hierarchy (~15 kB per page), see ?type=834
# i18n — headless default (lib.i18n); there is no language switch
# favicons — FaviconsJsonRenderer stays in the code base, unused
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
page.10.fields.favicons = USER page.10.fields.i18n >
page.10.fields.favicons.userFunc = Evomedien\Vitec\UserFunc\FaviconsJsonRenderer->render page.10.fields.favicons >
@@ -32,6 +32,23 @@ mod {
CType = vitec_productfinder CType = vitec_productfinder
} }
} }
productcheck {
iconIdentifier = vitec-plugin-productlist
title = Product Content Check (audit table)
description = Live audit of all product records - one row per product, one check per content field. Internal page /products/product-check.
tt_content_defValues {
CType = vitec_productcheck
}
}
pagecheck {
iconIdentifier = vitec-plugin-productlist
title = Page Content Check (markets / solutions audit)
description = Live audit of the market or solution page tree - one row per page, one check per content slot. Internal pages /markets/market-check and /solutions/solution-check.
tt_content_defValues {
CType = vitec_pagecheck
}
}
productshow { productshow {
iconIdentifier = vitec-plugin-productshow iconIdentifier = vitec-plugin-productshow
title = Show single VITEC Product title = Show single VITEC Product
@@ -3,9 +3,9 @@
| | | | | |
|---|---| |---|---|
| **Document identifier** | EVO‑VITEC‑HL‑001 | | **Document identifier** | EVO‑VITEC‑HL‑001 |
| **Version** | 1.30 | | **Version** | 1.32 |
| **Status** | Released | | **Status** | Released |
| **Date** | 2026‑11‑02 | | **Date** | 2026‑11‑05 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch | | **Owner** | evomedien — VITEC relaunch |
@@ -43,6 +43,9 @@
| 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. | | 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. |
| 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. | | 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. |
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). | | 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
| 1.33 | 2026‑11‑12 | **Interface change (additive).** Products menu: an overview page with exactly one product is presented as that product — `items[].link` is the product detail page and `items[].image` the product picture (productimage, else heroimage, else page media); new `items[].linkType` (`product` | `overview`) and `items[].overviewLink` (always the overview page). Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway, ActiveSQX (decision Harry 2026-11-12). Front end: no sub-product count for `linkType = product`. |
This document is drafted in the style of, and adopts the terminology conventions of, 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 ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
@@ -1210,38 +1213,62 @@ tile or `null`.
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).** **Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
The catalog payload is produced by the `ProvideProductCatalog` listener of The catalog payload is produced by the `ProvideProductCatalog` listener of
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
`/products`: sysfolders on the first level are the menu **categories**, `/products`: the pages on the first level are the menu **categories**, the
sysfolders below them the **groups** (both labels, never pages), and the pages below them the **groups** (sub-categories), and the pages below the
pages below the groups the **overview pages** (`/products/<slug>`, built by groups the **overview pages** (`/products/<slug>`, built by
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or `vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
**shortcut** pages. The menu ends at the overview pages; product detail **shortcut** pages. Since v1.32 categories and groups are clickable pages
pages are reached from there. with `link` (they were sysfolders/labels before); product detail pages are
reached from the overview pages.
```json ```json
"catalog": { "catalog": {
"version": 2, "version": 2,
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" }, "search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
"viewAll": { "title": "View all products", "link": "/products" }, "viewAll": { "title": "View all products", "link": "/products" },
"categories": [ { "title": "IP Video Streaming, Distribution & KVM", "categories": [ { "title": "IP Video Streaming, Distribution & KVM", "link": "/products/ip-video-streaming-distribution-and-kvm",
"groups": [ { "title": "Encoders & Decoders", "groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
"items": [ "items": [
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances", { "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
"linkType": "overview", "overviewLink": "/products/appliances",
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ], "productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
"image": null }, "image": null },
{ "type": "page", "uid": 575, "title": "Avedia", "link": "/product/avedia-platform-iptv-signage-cmsserver",
"linkType": "product", "overviewLink": "/products/avedia",
"productCount": 1, "products": [ { "title": "Avedia Platform", "link": "/product/avedia-platform-iptv-signage-cmsserver" } ],
"image": { "url": "/fileadmin/_processed_/…webp", "alternative": "", "width": 640, "height": 360 } },
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo", { "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ] "target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
} }
``` ```
- `categories[].link` / `groups[].link` (v1.32, additive): the category /
sub-category page; `""` only while a level is still a folder. `title` is
the menu label = the page's *nav_title* when set: six sub-categories are
the 2026-10 category pages (Joe's workbooks - content, slug and SEO kept,
e.g. "Encoders & Decoders" → `/products/video-encoding-and-streaming`,
H1 "Video Encoding & Streaming"). Breadcrumbs run
Home › Products › category › sub-category › overview page.
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's - `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
`target` holds zero-based indices into `categories` / `groups` / `items`: `target` holds zero-based indices into `categories` / `groups` / `items`:
with `page` it points to an overview page (and `link` is that page), with `page` it points to an overview page (and `link` is that page),
without `page` it opens a whole group (`link` is `""`). without `page` it opens a whole group (`link` is `""`; the group's own
page is `groups[].link` of the target).
- `products` are the product cards of the overview page that link a detail - `products` are the product cards of the overview page that link a detail
page (container children included, creation order); `productCount` is page (container children included, creation order); `productCount` is
their number - a card without a resolvable product is not counted. their number - a card without a resolvable product is not counted.
- **Single-product overviews (v1.33, additive):** an overview page with
exactly one product is presented as that product - `link` is the product
detail page (`/product/<slug>`) and `image` the product's own picture
(*productimage*, else *heroimage*, else the page media). `linkType` tells
the two cases apart: `product` | `overview`; `overviewLink` always holds
the overview page (`/products/<slug>`) for breadcrumb-style uses. Today
this applies to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway
and ActiveSQX; it switches automatically when an editor adds a second
card. The front end should not render a sub-product count for these.
- `image` (from content stage 2) is the overview page's first page media as a - `image` (from content stage 2) is the overview page's first page media as a
640 px WebP, `null` when none is set. 640 px WebP (`linkType = overview`) or the product picture (`linkType =
product`), `null` when none is set.
- The promo boxes of the left column ("Product highlight", "Product news") - The promo boxes of the left column ("Product highlight", "Product news")
are the entry's editorial `teasers` next to `catalog` (`kicker` = box are the entry's editorial `teasers` next to `catalog` (`kicker` = box
label, plus `title`, `text`, `link`, `image`; designed for two, an empty label, plus `title`, `text`, `link`, `image`; designed for two, an empty
@@ -1250,15 +1277,9 @@ pages are reached from there.
- Hidden pages and pages with *Hide in menu* drop out; order = page tree - Hidden pages and pages with *Hide in menu* drop out; order = page tree
order. Version 1 (2026-10-09, sys_category groups with products) is no order. Version 1 (2026-10-09, sys_category groups with products) is no
longer the source. longer the source.
- **Transitional v1 fields (deprecated, removed once the front end reads - The transitional v1 fields of v1.28 (`groups[].products`, `uid`s,
`items`).** The deployed front end still evaluated the v1 shape for the product `teaser`) were removed on 2026-11-03 after the front end switched
active state of the Products trigger and crashed the whole site without (0024-FE / 0029-BE). The `link` fields of v1.32 are new and permanent.
it. Until it has switched, v2 additionally carries: `categories[].uid`
(folder uid) and `categories[].link` (`/products`); `groups[].uid` (`0`),
`groups[].link` (link of the group's first overview page) and
`groups[].products` (the products of all its overview pages, deduplicated
by link); and a `teaser` (card text, plain) on every product. Do not build
on these fields.
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display` **Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
since v1.30).** Page-tree entries build their `columns` live from the since v1.30).** Page-tree entries build their `columns` live from the
@@ -1705,4 +1726,4 @@ remediation.
- Header convention — the uniform header section across CEs, plugins and containers. - Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point. - `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVO‑VITEC‑HL‑001 v1.30.* *End of document EVO‑VITEC‑HL‑001 v1.33.*
@@ -0,0 +1,326 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Module" />
<f:section name="Content">
<div class="vitec-cockpit" id="ssr-cockpit" data-json="{cockpitJson}">
<h1 class="vc-sr">VITEC SSR — Control &amp; Protocol</h1>
<div class="vc-grid">
<!-- Live band: node state, release, delivery switch, 24 h availability -->
<section class="vc-card vc-hero vc-s12" aria-label="Live status">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-live">?</button><span class="vc-tip" role="tooltip" id="vc-tip-live">Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA. Alert mails go to the addresses under Site settings › VITEC › SSR alert mail recipients; hover “Alert mail” to see them.</span></span>
<div class="vc-hero-top">
<div class="vc-live">
<f:if condition="{health}">
<f:then>
<span class="vc-pulse" id="vc-pulse" aria-hidden="true"></span>
<div>
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-ok-text">online</span></h2>
<p class="vc-live-sub">
<f:if condition="{flagOn}">
<f:then>Pages are rendered on the VPS</f:then>
<f:else>Node is ready, but delivery is off: TYPO3 serves the SPA</f:else>
</f:if>
<f:if condition="{lastSample}"> · last check <span title="{f:format.date(date: '@{lastSample.crdate}', format: 'Y-m-d H:i:s')}">{lastSampleAge} ago</span></f:if>
</p>
</div>
</f:then>
<f:else>
<span class="vc-pulse is-down" id="vc-pulse" aria-hidden="true"></span>
<div>
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-bad-text">not reachable</span></h2>
<p class="vc-live-sub">{healthError} · after {failoverAfter} failed checks in a row the cron switches delivery back to the SPA</p>
</div>
</f:else>
</f:if>
</div>
<dl class="vc-facts">
<div class="vc-fact">
<dt>Delivery</dt>
<dd>
<f:if condition="{flagOn}">
<f:then><span class="vc-pill is-ok">ON</span></f:then>
<f:else><span class="vc-pill is-muted">OFF</span></f:else>
</f:if>
<f:if condition="{settingsUri}">
<f:then><a href="{settingsUri}" class="vc-link" title="Site Management › Settings › VITEC › SSR delivery. Switching on purges the node cache once.">Site settings</a></f:then>
<f:else><span class="vc-dim" title="Site Management › Settings › VITEC › SSR delivery">via Site settings</span></f:else>
</f:if>
</dd>
</div>
<f:if condition="{health}">
<div class="vc-fact"><dt>Release</dt><dd><code>{health.release.release}</code></dd></div>
<div class="vc-fact"><dt>Commit</dt><dd><code>{health.release.commit}</code></dd></div>
</f:if>
<div class="vc-fact">
<dt>Purge webhook</dt>
<dd>
<f:if condition="{configured}">
<f:then><span class="vc-pill is-ok" title="Endpoint and secret from var/vitec-ssr-purge.env">configured</span></f:then>
<f:else><span class="vc-pill is-warn" title="var/vitec-ssr-purge.env is missing or incomplete">not configured</span></f:else>
</f:if>
</dd>
</div>
<div class="vc-fact">
<dt>Alert mail</dt>
<dd>
<f:if condition="{alertCount}">
<f:then><span class="vc-pill {f:if(condition: alertInvalid, then: 'is-warn', else: 'is-ok')}" title="{alertList} (from {alert.source}){f:if(condition: alertInvalid, then: ' · skipped, not valid: {alertInvalid}')}">{alertCount} recipient<f:if condition="{alertCount} != 1">s</f:if></span></f:then>
<f:else><span class="vc-pill is-warn" title="No alert mails are sent. Add addresses in Site Management › Settings › VITEC › SSR alert mail recipients.">none</span></f:else>
</f:if>
</dd>
</div>
<div class="vc-fact">
<dt>Auto failover</dt>
<dd><span class="vc-pill is-navy" title="Cron vitec:ssr-health every 5 minutes: alert mail and fallback to the SPA after {failoverAfter} failed checks in a row">after {failoverAfter} failed checks</span></dd>
</div>
</dl>
</div>
<div class="vc-uptime">
<div class="vc-uptime-h">
<span>Availability, last 24 hours · one bar per 15 minutes</span>
<span id="vc-uptime-sum">–</span>
</div>
<div class="vc-bars" id="vc-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours"></div>
<div class="vc-uptime-f" id="vc-uptime-axis"></div>
</div>
</section>
<!-- Gauges -->
<section class="vc-card vc-s3" aria-label="Cache hit rate">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-hit">?</button><span class="vc-tip" role="tooltip" id="vc-tip-hit">Share of page requests in the last 24 hours that the node answered from its cache, either fresh or stale and refreshed in the background. All others had to be rendered first. Higher is better. It drops after a full purge until the cache is warm again. The arrow compares with the 24 hours before.</span></span>
<div class="vc-tile">
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-ok" id="vc-ring-hit" cx="48" cy="48" r="40"/></svg>
<div class="vc-tile-body">
<div class="vc-label">Hit rate · 24 h</div>
<div class="vc-big"><span id="vc-hit">–</span><small>%</small></div>
<div class="vc-sub" id="vc-hit-trend">served from cache or refreshed in the background</div>
</div>
</div>
</section>
<section class="vc-card vc-s3" aria-label="Response time">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-ms">?</button><span class="vc-tip" role="tooltip" id="vc-tip-ms">The big number is the last check: how long TYPO3 needed to reach the node, measured when this page loads and every 15 seconds after. Median and p95 come from the cron samples of the last 24 hours, taken every 5 minutes. The curve shows the last 4 hours. Values above 400 ms turn the availability strip yellow.</span></span>
<div class="vc-label">Response time</div>
<div class="vc-big"><span id="vc-ms"><f:if condition="{health}"><f:then>{health.responseMs}</f:then><f:else>–</f:else></f:if></span><small>ms</small></div>
<div class="vc-big-note">last check · updates every 15 s</div>
<div class="vc-sub">median 24 h <b id="vc-ms-median">–</b> · p95 <b id="vc-ms-p95">–</b></div>
<svg class="vc-spark" id="vc-spark" viewBox="0 0 300 46" preserveAspectRatio="none" aria-hidden="true"></svg>
</section>
<section class="vc-card vc-s3" aria-label="Cache fill">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-cache">?</button><span class="vc-tip" role="tooltip" id="vc-tip-cache">How much of the node page cache is in use: pages and megabytes against the configured limit (SSR_CACHE_LIMIT_MB). When the cache is full, the pages used least recently drop out. After “Purge everything” it falls to almost zero until “Warm cache” fills it again.</span></span>
<div class="vc-tile">
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-sky" id="vc-ring-cache" cx="48" cy="48" r="40"/></svg>
<div class="vc-tile-body">
<div class="vc-label">Cache fill</div>
<div class="vc-big"><span id="vc-cache-mb">{cockpit.cacheMb}</span><small id="vc-cache-limit"><f:if condition="{cacheLimitMb}"><f:then>/ {cacheLimitMb} MB</f:then><f:else>MB</f:else></f:if></small></div>
<div class="vc-sub"><b id="vc-cache-pages">{cockpit.cachedPages}</b> pages cached<span id="vc-cache-pct"></span></div>
</div>
</div>
</section>
<section class="vc-card vc-s3" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errtile">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errtile">Errors that visitor browsers reported from the React frontend in the last 24 hours. A chunk reload happens when an open tab still runs an older bundle after a deploy; that is usually harmless. JS errors point to a real bug. Test reports (smoke tests, curl) are left out; reports from crawlers like Googlebot count and carry a bot label. The bars show reports per hour, details are in the Client errors card below.</span></span>
<div class="vc-label">Client errors · 24 h</div>
<div class="vc-big">{clientErrorCount24h}<small>report<f:if condition="{clientErrorCount24h} != 1">s</f:if></small></div>
<div class="vc-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if><f:if condition="{testCount24h}"> · <span class="vc-dim" title="Smoke tests and curl reports are left out">{testCount24h} test<f:if condition="{testCount24h} != 1">s</f:if> left out</span></f:if></div>
<div class="vc-mini" id="vc-mini" role="img" aria-label="Client errors per hour, last 24 hours"></div>
</section>
<!-- Traffic chart -->
<section class="vc-card vc-s8 vc-chart-card" aria-label="Traffic and cache">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-traffic">?</button><span class="vc-tip" role="tooltip" id="vc-tip-traffic">Requests per hour, or per 4 hours in the 7-day view, split by how the node answered: served from cache, stale and refreshed in the background, or rendered fresh. The orange line is the response time, red triangles mark purges. Spikes of fresh renders usually follow a purge or a warm run. Source: node counters sampled every 5 minutes.</span></span>
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 3v18h18M7 15l4-5 4 3 5-7"/></svg>Traffic and cache</h2>
<div class="vc-seg" role="group" aria-label="Time range">
<button type="button" data-range="24h" aria-pressed="true">24 h</button>
<button type="button" data-range="7d" aria-pressed="false">7 days</button>
</div>
</div>
<div class="vc-legend">
<span><i class="is-navy"></i>Served from cache</span>
<span><i class="is-sky"></i>Stale, refreshed in background</span>
<span><i class="is-warn"></i>Rendered fresh</span>
<span><i class="is-orange is-round"></i>Response time</span>
<span><i class="is-bad is-tri"></i>Purge</span>
</div>
<div class="vc-chart"><canvas id="vc-traffic" aria-label="Requests by cache result, with response time and purges"></canvas></div>
<p class="vc-empty" id="vc-traffic-empty" hidden>No traffic counters in this range yet. The cron <code>vitec:ssr-health</code> collects them every 5 minutes.</p>
<dl class="vc-stats">
<div><dt>Requests</dt><dd id="vc-st-req">–</dd></div>
<div><dt>Hit rate</dt><dd id="vc-st-hit">–</dd></div>
<div><dt>Render errors</dt><dd id="vc-st-err">–</dd></div>
<div><dt>Purges</dt><dd id="vc-st-purge">–</dd></div>
</dl>
</section>
<!-- Actions -->
<section class="vc-card vc-s4" aria-label="Actions">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-actions">?</button><span class="vc-tip" role="tooltip" id="vc-tip-actions">Warm cache renders every sitemap page in advance, and the ring shows the progress live. Purge paths drops single pages from the cache, for example after a change the auto-purge did not catch. Purge everything empties the whole cache, which is needed after menu or footer changes and after a deploy; run Warm cache right after. Both heavy actions ask for confirmation.</span></span>
<div class="vc-card-h"><h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>Actions</h2></div>
<div class="vc-actions">
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post" class="vc-act"
data-confirm-title="Warm the whole cache?"
data-confirm="Renders every sitemap page (about 850) in the background and sends a few thousand requests to TYPO3. Takes about one minute with a warm TYPO3 cache, up to ten minutes with a cold one."
data-confirm-ok="Start warming" data-confirm-severity="primary">
<div class="vc-act-h"><h3 class="vc-act-t">Warm cache</h3><span class="vc-pill" id="vc-warm-pill" hidden></span></div>
<div class="vc-warm">
<div class="vc-warm-ring">
<svg width="92" height="92" viewBox="0 0 96 96" aria-hidden="true" class="vc-ring"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-orange is-fast" id="vc-ring-warm" cx="48" cy="48" r="40"/></svg>
<div class="vc-warm-pct" id="vc-warm-pct">–</div>
</div>
<div class="vc-warm-meta" id="vc-warm-meta">no run since the server started</div>
</div>
<div class="vc-btn-row">
<button type="submit" class="vc-btn is-primary" id="ssr-warm-button" {f:if(condition: health.warming.running, then: 'disabled="disabled"')}>
<core:icon identifier="actions-play" size="small" /><span id="vc-warm-label">{f:if(condition: health.warming.running, then: 'Warming …', else: 'Warm cache')}</span>
</button>
</div>
<p class="vc-help">Renders every sitemap page in advance, so no visitor waits for a first render. Run it after “Purge everything” or a deploy.</p>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act">
<h3 class="vc-act-t">Purge paths</h3>
<label class="vc-lbl" for="purge-paths">Paths, one per line</label>
<textarea class="vc-input" id="purge-paths" name="paths" rows="3" placeholder="/products/aligo&#10;/insights/news"></textarea>
<input type="hidden" name="mode" value="paths">
<div class="vc-btn-row"><button type="submit" class="vc-btn is-ghost"><core:icon identifier="actions-refresh" size="small" />Purge these paths</button></div>
<p class="vc-help">Drops those pages, all hosts and query variants, from the SSR cache. They render again on their next request.</p>
</form>
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act"
data-confirm-title="Purge the entire SSR cache?"
data-confirm="Every cached page is dropped and rendered again on its next request. Run “Warm cache” afterwards so visitors do not wait for the first render."
data-confirm-ok="Purge everything" data-confirm-severity="danger">
<h3 class="vc-act-t">Purge everything</h3>
<p class="vc-help">For menu or footer changes while auto-purge is off, and after a frontend deploy.</p>
<input type="hidden" name="mode" value="all">
<div class="vc-btn-row"><button type="submit" class="vc-btn is-danger"><core:icon identifier="actions-delete" size="small" />Purge everything</button></div>
</form>
</div>
</section>
<!-- Purge activity -->
<section class="vc-card vc-s7" aria-label="Purge activity">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-activity">?</button><span class="vc-tip" role="tooltip" id="vc-tip-activity">Every cache purge, newest first. The colour shows the trigger: auto-purge when an editor saves, manual from this module, context menu from the page tree, cli for commands and deploys, switch-on when SSR delivery was turned on. Result and duration are on the right. Switch between 30, 100 and 500 entries.</span></span>
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Purge activity</h2>
<div class="vc-seg" role="group" aria-label="Entries to show">
<a href="{moduleUri}&amp;limit=30" aria-pressed="{f:if(condition: '{purgeLimit} == 30', then: 'true', else: 'false')}">30</a>
<a href="{moduleUri}&amp;limit=100" aria-pressed="{f:if(condition: '{purgeLimit} == 100', then: 'true', else: 'false')}">100</a>
<a href="{moduleUri}&amp;limit=500" aria-pressed="{f:if(condition: '{purgeLimit} == 500', then: 'true', else: 'false')}">500</a>
</div>
</div>
<div class="vc-legend vc-legend-tl">
<span><i class="is-ok is-round"></i>auto-purge</span>
<span><i class="is-orange is-round"></i>manual</span>
<span><i class="is-navy is-round"></i>context menu</span>
<span><i class="is-sky is-round"></i>cli / deploy</span>
<span><i class="is-warn is-round"></i>switch-on</span>
</div>
<f:if condition="{purgeLog}">
<f:then>
<ol class="vc-timeline">
<f:for each="{purgeLog}" as="row">
<f:if condition="{row.day_first}"><li class="vc-day">{row.day_label}</li></f:if>
<li class="vc-ev">
<time title="{f:format.date(date: '@{row.crdate}', format: 'Y-m-d H:i:s')}"><f:format.date format="H:i">@{row.crdate}</f:format.date></time>
<span class="vc-dot is-{row.trigger_tone}"></span>
<div class="vc-ev-main">
<div class="vc-ev-line">
<span class="vc-pill is-{row.trigger_tone}">{row.trigger_label}</span>
<f:if condition="{row.trigger_who}"><span class="vc-dim">{row.trigger_who}</span></f:if>
</div>
<div class="vc-paths">
<f:if condition="{row.paths_all}">
<f:then><span class="vc-pill is-warn">all pages (*)</span></f:then>
<f:else>
<f:for each="{row.paths_first}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
<f:if condition="{row.paths_rest}">
<details class="vc-more">
<summary>+<f:count subject="{row.paths_rest}" /> more</summary>
<f:for each="{row.paths_rest}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
</details>
</f:if>
</f:else>
</f:if>
</div>
</div>
<div class="vc-ev-r">
<f:if condition="{row.success}">
<f:then><span class="vc-pill is-ok">ok</span></f:then>
<f:else><span class="vc-pill is-bad" title="{row.message}">HTTP {row.http_code}</span></f:else>
</f:if>
<span>{row.duration_ms} ms</span>
</div>
</li>
</f:for>
</ol>
</f:then>
<f:else><p class="vc-empty">No purges logged yet. Saving a page, the context menu “Purge from SSR cache” or the buttons on the right create the first entry.</p></f:else>
</f:if>
</section>
<!-- Client errors -->
<section class="vc-card vc-s5" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errors">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errors">Every report from visitor browsers in the last 24 hours, newest first. Open an entry for release, URL, browser, anonymized IP and stack trace. “old bundle” means the tab ran an older release than the node. Test reports (smoke tests, curl) are counted under Tests but not listed. Reports are limited to 30 per minute and IP and are kept for 30 days.</span></span>
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3 2 21h20zM12 10v4M12 17.5v.5"/></svg>Client errors</h2>
<span class="vc-pill is-sky" title="Reported by the React frontend via POST /api/vitec/client-error; max. 30 per minute and IP, IPs anonymized, cleaned up after 30 days by cron vitec:client-error-cleanup">kept 30 days</span>
</div>
<dl class="vc-err-sum">
<div><dt>24 h</dt><dd>{clientErrorCount24h}</dd></div>
<div><dt>Chunk reloads</dt><dd class="vc-warn-text">{chunkCount24h}</dd></div>
<div><dt>JS errors</dt><dd class="vc-bad-text">{jsErrorCount24h}</dd></div>
<div title="Smoke tests and curl reports are not counted and not listed"><dt>Tests</dt><dd class="vc-muted-text">{testCount24h}</dd></div>
</dl>
<f:if condition="{clientErrors}">
<f:then>
<div class="vc-errs">
<f:for each="{clientErrors}" as="row">
<details class="vc-err">
<summary>
<span class="vc-err-pills">
<f:if condition="{row.is_chunk_reload}">
<f:then><span class="vc-pill is-warn">chunk reload</span></f:then>
<f:else><span class="vc-pill is-bad">error</span></f:else>
</f:if>
<f:if condition="{row.is_bot}"><span class="vc-pill is-muted" title="Reported by a crawler, e.g. Googlebot. Still a real error: the page broke for this bot.">bot</span></f:if>
</span>
<span class="vc-err-msg">{row.message}<small>{row.path}</small></span>
<span class="vc-err-t"><f:format.date format="H:i">@{row.crdate}</f:format.date></span>
</summary>
<div class="vc-err-body">
<dl class="vc-kv">
<dt>Reported</dt><dd><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date> · entry #{row.uid}</dd>
<dt>Release</dt><dd><f:if condition="{row.release}"><f:then><code>{row.release}</code><f:if condition="{health.release.release}"><f:if condition="{row.release} != {health.release.release}"> <span class="vc-pill is-warn">old bundle</span></f:if></f:if></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>URL</dt><dd><f:if condition="{row.url}"><f:then><a class="vc-link" href="{row.url}" target="_blank" rel="noopener">{row.url}</a></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>Browser</dt><dd><f:if condition="{row.user_agent}"><f:then>{row.user_agent}</f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
<dt>IP</dt><dd><code>{row.ip}</code> <span class="vc-dim">anonymized</span></dd>
</dl>
<f:if condition="{row.stack}">
<f:then><pre class="vc-stack">{row.stack}</pre></f:then>
<f:else><p class="vc-dim">No stack transmitted.</p></f:else>
</f:if>
</div>
</details>
</f:for>
</div>
</f:then>
<f:else><p class="vc-empty">No client errors in the last 24 hours. The frontend reports JS errors and chunk reloads here as they happen.</p></f:else>
</f:if>
</section>
</div>
</div>
</f:section>
</html>
@@ -0,0 +1,271 @@
/* VITEC SSR cockpit (module web_vitecssr).
Deliberately dark, in VITEC colours, independent of the backend theme
(decision Harry, 2026-11-11). The TYPO3 frame stays core: doc header,
flash messages, confirmation modals. Everything here is scoped to
.vitec-cockpit or to this module's body.
Layout: live band + availability / four gauges / traffic 8 + actions 4 /
purge activity 7 + client errors 5; below 1200 px the wide pairs stack. */
[data-module-name="web_vitecssr"] .module-body {
--module-bg: #080c26;
background-color: #080c26;
}
[data-module-name="web_vitecssr"] .module-body-container {
max-width: 1680px;
}
.vitec-cockpit {
--vc-ink: #080c26; /* page: VITEC navy pushed to night */
--vc-panel: #0f1640; /* card */
--vc-panel-2: #151e52; /* raised inside a card */
--vc-line: rgba(140, 156, 255, .14);
--vc-line-strong: rgba(140, 156, 255, .28);
--vc-navy: #26358c; /* VITEC blue */
--vc-navy-hi: #4a5fd6; /* VITEC blue lifted for the dark ground */
--vc-orange: #f47937; /* VITEC orange: actions and highlights */
--vc-text: #e9ecfb;
--vc-muted: #8e97c6;
--vc-faint: #6a73a6;
--vc-ok: #35d39a;
--vc-warn: #ffc65c;
--vc-bad: #ff5f73;
--vc-sky: #6fc3ff;
--vc-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
--vc-r: 14px;
color-scheme: dark;
color: var(--vc-text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.vitec-cockpit *,
.vitec-cockpit *::before,
.vitec-cockpit *::after { box-sizing: border-box; }
.vitec-cockpit code { font-family: var(--vc-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
.vitec-cockpit h2, .vitec-cockpit h3 { color: inherit; }
.vc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.vc-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 20px; }
.vc-card { background: var(--vc-panel); border: 1px solid var(--vc-line); border-radius: var(--vc-r); padding: 20px; min-width: 0; }
.vc-s12 { grid-column: span 12; } .vc-s8 { grid-column: span 8; } .vc-s4 { grid-column: span 4; }
.vc-s7 { grid-column: span 7; } .vc-s5 { grid-column: span 5; } .vc-s3 { grid-column: span 3; }
@media (max-width: 1199.98px) {
.vc-s8, .vc-s4, .vc-s7, .vc-s5 { grid-column: span 12; }
.vc-s3 { grid-column: span 6; }
}
@media (max-width: 619.98px) { .vc-s3 { grid-column: span 12; } .vc-grid { gap: 14px; } .vc-card { padding: 16px; } }
.vc-card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.vc-card-t { font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted) !important; margin: 0; display: flex; align-items: center; gap: 8px; }
.vc-card-t svg { color: var(--vc-navy-hi); }
.vc-dim { color: var(--vc-muted); font-size: 12px; }
.vc-ok-text { color: var(--vc-ok); } .vc-bad-text { color: var(--vc-bad); } .vc-warn-text { color: var(--vc-warn); }
.vc-link { color: var(--vc-sky); text-decoration: none; font-size: 12px; font-weight: 500; }
.vc-link:hover, .vc-link:focus { color: var(--vc-sky); text-decoration: underline; }
/* Pills */
.vc-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .04em; padding: 5px 9px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
.vc-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vc-ok); }
.vc-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vc-warn); }
.vc-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vc-bad); }
.vc-pill.is-sky, .vc-pill.is-info { background: rgba(111, 195, 255, .14); color: var(--vc-sky); }
.vc-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
.vc-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vc-orange); }
.vc-pill.is-muted { background: rgba(140, 156, 255, .1); color: var(--vc-muted); }
/* Live band */
.vc-hero { padding: 24px 24px 20px; background:
radial-gradient(120% 140% at 0% 0%, rgba(74, 95, 214, .35), transparent 55%),
radial-gradient(80% 120% at 100% 0%, rgba(244, 121, 55, .12), transparent 50%),
var(--vc-panel); }
.vc-hero-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px 28px; }
.vc-live { display: flex; align-items: center; gap: 14px; min-width: 0; }
.vc-pulse { position: relative; width: 14px; height: 14px; border-radius: 50%; background: var(--vc-ok); flex: none; }
.vc-pulse::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid currentColor; color: var(--vc-ok); opacity: 0; animation: vc-ping 2.4s ease-out infinite; }
.vc-pulse.is-down { background: var(--vc-bad); }
.vc-pulse.is-down::after { color: var(--vc-bad); }
@keyframes vc-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
.vc-live-title { font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; margin: 0; }
.vc-live-sub { margin: 2px 0 0; color: var(--vc-muted); font-size: 13px; }
.vc-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 0 0 0 auto; }
.vc-fact dt { font-size: 11px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); }
.vc-fact dd { margin: 2px 0 0; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
@media (max-width: 899.98px) { .vc-facts { margin-left: 0; } }
.vc-uptime { margin-top: 22px; }
.vc-uptime-h { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--vc-muted); margin-bottom: 8px; }
.vc-uptime-h b { color: var(--vc-text); font-weight: 600; }
.vc-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 2px; height: 30px; align-items: end; }
.vc-bars i { display: block; height: 100%; border-radius: 2px; background: var(--vc-ok); opacity: .85; transition: opacity .15s, transform .15s; }
.vc-bars i.is-slow { background: var(--vc-warn); }
.vc-bars i.is-down { background: var(--vc-bad); }
.vc-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
.vc-bars i:hover { opacity: 1; transform: scaleY(1.12); }
@media (max-width: 619.98px) { .vc-bars { gap: 1px; } }
.vc-uptime-f { display: flex; justify-content: space-between; font: 11px var(--vc-mono); color: var(--vc-faint); margin-top: 6px; }
/* Gauges */
.vc-tile { display: flex; gap: 14px; align-items: center; }
.vc-tile .vc-ring { width: 78px; height: 78px; }
.vc-tile-body { min-width: 0; flex: 1; }
.vc-label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted); font-weight: 600; }
.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; }
.vc-sub { font-size: 12px; color: var(--vc-muted); }
.vc-big-note { font-size: 10.5px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); margin: -2px 0 6px; }
.vc-sub b { color: var(--vc-text); }
.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); }
.vc-ring { flex: none; }
.vc-ring circle { fill: none; stroke-width: 9; }
.vc-ring .vc-track { stroke: rgba(140, 156, 255, .12); }
.vc-ring .vc-val { stroke-linecap: round; transform: rotate(-90deg); transform-origin: 50% 50%; stroke-dasharray: 251.33; stroke-dashoffset: 251.33; transition: stroke-dashoffset 1s cubic-bezier(.2, .8, .2, 1); }
.vc-ring .vc-val.is-fast { transition-duration: .4s; }
.vc-val.is-ok { stroke: var(--vc-ok); } .vc-val.is-sky { stroke: var(--vc-sky); } .vc-val.is-orange { stroke: var(--vc-orange); }
.vc-spark { display: block; width: 100%; height: 46px; margin-top: 6px; }
.vc-mini { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 2px; align-items: end; height: 40px; margin-top: 8px; }
.vc-mini i { display: block; background: var(--vc-navy-hi); border-radius: 2px 2px 0 0; min-height: 2px; }
.vc-mini i.is-hot { background: var(--vc-bad); }
/* Segmented control */
.vc-seg { display: inline-flex; background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 99px; padding: 3px; }
.vc-seg button, .vc-seg a { border: 0; background: transparent; color: var(--vc-muted); font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 99px; cursor: pointer; text-decoration: none; line-height: 1.4; }
.vc-seg button:hover, .vc-seg a:hover { color: var(--vc-text); text-decoration: none; }
.vc-seg [aria-pressed="true"] { background: var(--vc-navy); color: #fff; }
.vitec-cockpit :is(button, a, textarea, summary):focus-visible { outline: 2px solid var(--vc-orange); outline-offset: 2px; }
.vc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--vc-muted); }
.vc-legend span { display: inline-flex; align-items: center; gap: 6px; }
.vc-legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
.vc-legend i.is-navy { background: var(--vc-navy-hi); } .vc-legend i.is-sky { background: var(--vc-sky); }
.vc-legend i.is-warn { background: var(--vc-warn); } .vc-legend i.is-orange { background: var(--vc-orange); }
.vc-legend i.is-bad { background: var(--vc-bad); } .vc-legend i.is-ok { background: var(--vc-ok); }
.vc-legend i.is-round { border-radius: 50%; }
.vc-legend i.is-tri { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.vc-legend-tl { margin: -6px 0 10px; }
.vc-chart-card { display: flex; flex-direction: column; }
.vc-chart { position: relative; flex: 1 1 auto; min-height: 320px; margin-top: 12px; overflow: hidden; }
.vc-chart canvas { max-width: 100%; }
@media (max-width: 619.98px) { .vc-chart { min-height: 260px; } }
.vc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--vc-line); }
.vc-stats dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
.vc-stats dd { margin: 2px 0 0; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
@media (max-width: 619.98px) { .vc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.vc-empty { color: var(--vc-muted); font-size: 13px; margin: 12px 0 0; }
/* Actions */
.vc-actions { display: flex; flex-direction: column; gap: 14px; }
.vc-act { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 12px; padding: 16px; margin: 0; }
.vc-act-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.vc-act-t { font-size: 15px; font-weight: 700; margin: 0; }
.vc-help { margin: 8px 0 0; color: var(--vc-muted); font-size: 12.5px; }
.vc-warm { display: flex; gap: 16px; align-items: center; margin-top: 12px; }
.vc-warm-ring { position: relative; flex: none; width: 92px; height: 92px; }
.vc-warm-pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
.vc-warm-meta { font-size: 12.5px; color: var(--vc-muted); min-width: 0; }
.vc-warm-meta b { color: var(--vc-text); font-variant-numeric: tabular-nums; }
.vc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.vc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; border: 1px solid transparent; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 9px 14px; cursor: pointer; transition: transform .1s, filter .15s, background-color .15s, border-color .15s; }
.vc-btn:active { transform: scale(.98); }
.vc-btn.is-primary { background: var(--vc-orange); color: #1b0b02; }
.vc-btn.is-primary:hover { filter: brightness(1.08); }
.vc-btn.is-primary[disabled] { background: rgba(244, 121, 55, .3); color: rgba(255, 255, 255, .65); cursor: progress; filter: none; }
.vc-btn.is-ghost { background: transparent; color: var(--vc-text); border-color: var(--vc-line-strong); }
.vc-btn.is-ghost:hover { border-color: var(--vc-navy-hi); background: rgba(74, 95, 214, .14); }
.vc-btn.is-danger { background: transparent; color: var(--vc-bad); border-color: rgba(255, 95, 115, .45); }
.vc-btn.is-danger:hover { background: rgba(255, 95, 115, .15); }
.vc-lbl { display: block; font-size: 12px; color: var(--vc-muted); margin: 12px 0 6px; font-weight: 600; }
.vc-input { width: 100%; resize: vertical; min-height: 84px; background: var(--vc-ink); color: var(--vc-text); border: 1px solid var(--vc-line-strong); border-radius: 9px; padding: 10px 12px; font: 12.5px/1.6 var(--vc-mono); }
.vc-input::placeholder { color: var(--vc-faint); }
.vc-input:focus { border-color: var(--vc-navy-hi); outline: none; }
/* Purge activity timeline */
.vc-timeline { list-style: none; margin: 0; padding: 0; position: relative; }
.vc-timeline::before { content: ""; position: absolute; left: 62px; top: 6px; bottom: 6px; width: 1px; background: var(--vc-line-strong); }
.vc-ev { display: grid; grid-template-columns: 52px 22px minmax(0, 1fr) auto; gap: 0 10px; align-items: start; padding: 10px 0; }
.vc-ev + .vc-ev { border-top: 1px dashed rgba(140, 156, 255, .08); }
.vc-ev time { font: 500 12px var(--vc-mono); color: var(--vc-muted); text-align: right; padding-top: 2px; }
.vc-dot { width: 11px; height: 11px; border-radius: 50%; margin: 5px auto 0; box-shadow: 0 0 0 4px var(--vc-panel); position: relative; z-index: 1; background: var(--vc-sky); }
.vc-dot.is-ok { background: var(--vc-ok); } .vc-dot.is-orange { background: var(--vc-orange); }
.vc-dot.is-navy { background: var(--vc-navy-hi); } .vc-dot.is-warn { background: var(--vc-warn); }
.vc-ev-main { min-width: 0; }
.vc-ev-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
.vc-paths { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
.vc-path { font: 12px var(--vc-mono); background: rgba(140, 156, 255, .08); color: #cdd4ff; border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vc-more { font-size: 12px; }
.vc-more summary { color: var(--vc-sky); cursor: pointer; list-style: none; padding: 2px 4px; }
.vc-more summary::-webkit-details-marker { display: none; }
.vc-more[open] { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
.vc-more[open] summary { flex-basis: 100%; }
.vc-ev-r { text-align: right; font: 500 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.vc-day { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); padding: 14px 0 4px 74px; }
.vc-day:first-child { padding-top: 0; }
@media (max-width: 619.98px) {
.vc-timeline::before { left: 46px; }
.vc-ev { grid-template-columns: 38px 18px minmax(0, 1fr); }
.vc-ev-r { grid-column: 3; flex-direction: row; align-items: center; margin-top: 6px; }
.vc-day { padding-left: 58px; }
}
/* Client errors */
.vc-err-sum { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
@media (max-width: 619.98px) { .vc-err-sum { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.vc-muted-text { color: var(--vc-faint); }
.vc-err-pills { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; }
.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }
.vc-errs { display: flex; flex-direction: column; gap: 8px; }
.vc-err { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 10px; }
.vc-err[open] { border-color: var(--vc-line-strong); }
.vc-err summary { list-style: none; cursor: pointer; padding: 12px 14px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; }
.vc-err summary::-webkit-details-marker { display: none; }
.vc-err-msg { min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vc-err-msg small { display: block; color: var(--vc-muted); font: 11.5px var(--vc-mono); overflow: hidden; text-overflow: ellipsis; }
.vc-err-t { font: 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; }
.vc-err-body { padding: 0 14px 14px; font-size: 12.5px; }
.vc-kv { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 6px 12px; margin: 0; }
.vc-kv dt { color: var(--vc-faint); font-weight: 400; }
.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.vc-stack { margin: 10px 0 0; background: var(--vc-ink); border: 0; border-radius: 8px; padding: 10px 12px; font: 11.5px/1.55 var(--vc-mono); color: #b9c1ef; overflow: auto; max-height: 320px; white-space: pre; }
/* Info button with tooltip, top right in every card (pure CSS: hover
or keyboard focus shows the text). */
.vc-card { position: relative; }
.vc-card-h { padding-right: 34px; }
.vc-hero-top { padding-right: 34px; }
/* Button and tooltip are placed against the card, so the tooltip never
gets wider than its card or leaves the module at the left edge. */
.vc-info-btn {
position: absolute; top: 14px; right: 14px; z-index: 21;
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
border-radius: 50%; border: 1px solid var(--vc-line-strong);
background: rgba(140, 156, 255, .1); color: var(--vc-muted);
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
transition: color .15s, border-color .15s, background-color .15s;
}
.vc-info:hover .vc-info-btn, .vc-info:focus-within .vc-info-btn {
color: #fff; border-color: var(--vc-orange); background: rgba(244, 121, 55, .22);
}
.vc-tip {
position: absolute; top: 48px; right: 8px; z-index: 20; width: min(320px, calc(100% - 16px));
padding: 12px 14px; border-radius: 10px;
background: #1a2460; border: 1px solid var(--vc-navy-hi);
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
color: var(--vc-text); font-size: 12.5px; font-weight: 400; line-height: 1.55;
letter-spacing: 0; text-transform: none; text-align: left; white-space: normal;
opacity: 0; visibility: hidden; transform: translateY(-4px);
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
}
.vc-tip::before {
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
background: #1a2460; border-left: 1px solid var(--vc-navy-hi); border-top: 1px solid var(--vc-navy-hi);
transform: rotate(45deg);
}
.vc-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
.vc-info:hover .vc-tip, .vc-info:focus-within .vc-tip {
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
.vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; }
}
@@ -0,0 +1,165 @@
/* VITEC SSR dashboard tiles in the look of the "VITEC SSR" cockpit module
(dark VITEC navy, orange accents; decision Harry, 2026-11-11).
Loaded per widget via AdditionalCssInterface; markup from
Classes/Widgets/CockpitHtml.php. Only touches widgets that contain
.vc-widget, so the rest of the dashboard keeps the core look. */
.widget:has(.vc-widget) {
--widget-bg: #0f1640;
--widget-color: #e9ecfb;
--widget-border-color: rgba(140, 156, 255, .18);
--widget-title-bg: #121b4c;
--widget-title-color: #e9ecfb;
--widget-box-shadow: 0 6px 24px rgba(8, 12, 38, .25);
color-scheme: dark;
}
.vc-widget {
--vcw-panel-2: #151e52;
--vcw-line: rgba(140, 156, 255, .14);
--vcw-navy: #4a5fd6;
--vcw-orange: #f47937;
--vcw-text: #e9ecfb;
--vcw-muted: #8e97c6;
--vcw-faint: #6a73a6;
--vcw-ok: #35d39a;
--vcw-warn: #ffc65c;
--vcw-bad: #ff5f73;
--vcw-sky: #6fc3ff;
--vcw-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
/* Widgets based on WidgetInterface get no inner padding from the core. */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: radial-gradient(120% 90% at 0% 0%, rgba(74, 95, 214, .22), transparent 60%), #0f1640;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--vcw-text);
font-size: 13px;
line-height: 1.45;
}
.vc-widget code { font-family: var(--vcw-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
.vcw-ok { color: var(--vcw-ok); } .vcw-warn { color: var(--vcw-warn); } .vcw-bad { color: var(--vcw-bad); }
.vcw-dim { color: var(--vcw-muted); font-size: 12px; margin: 0; }
.vcw-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-muted); }
.vcw-sub { font-size: 12px; color: var(--vcw-muted); }
.vcw-right { text-align: right; }
.vcw-num { font-size: 26px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.vcw-num small, .vcw-val small { font-size: 12px; font-weight: 600; color: var(--vcw-muted); margin-left: 3px; letter-spacing: 0; }
/* Head with pulse */
.vcw-head { display: flex; align-items: center; gap: 12px; }
.vcw-title { font-size: 22px; font-weight: 800; line-height: 1.15; }
.vcw-pulse { position: relative; flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--vcw-ok); color: var(--vcw-ok); }
.vcw-pulse::after { content: ""; position: absolute; inset: -5px; border-radius: 50%; border: 2px solid currentColor; opacity: 0; animation: vcw-ping 2.4s ease-out infinite; }
.vcw-pulse.is-warn { background: var(--vcw-warn); color: var(--vcw-warn); }
.vcw-pulse.is-down { background: var(--vcw-bad); color: var(--vcw-bad); }
@keyframes vcw-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
/* Ring gauge */
.vcw-ring { position: relative; flex: none; }
.vcw-ring svg { display: block; transform: rotate(-90deg); }
.vcw-ring circle { fill: none; stroke-width: 9; }
.vcw-track { stroke: rgba(140, 156, 255, .12); }
.vcw-val { stroke-linecap: round; }
circle.vcw-val.is-ok { stroke: var(--vcw-ok); } circle.vcw-val.is-sky { stroke: var(--vcw-sky); } circle.vcw-val.is-orange { stroke: var(--vcw-orange); }
.vcw-ring-c { position: absolute; inset: 0; display: grid; place-items: center; font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; }
.vcw-gauge { display: flex; align-items: center; gap: 14px; min-width: 0; }
.vcw-grow { flex: 1; min-width: 0; }
.vcw-split { display: flex; flex-direction: column; gap: 14px; }
.vcw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px 16px; margin: 0; }
.vcw-facts > div { min-width: 0; }
.vcw-facts dt { font-size: 10.5px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-faint); }
.vcw-facts dd { margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Pills */
.vcw-pill { display: inline-flex; align-items: center; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .03em; padding: 4px 8px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
.vcw-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vcw-ok); }
.vcw-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vcw-warn); }
.vcw-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vcw-bad); }
.vcw-pill.is-sky { background: rgba(111, 195, 255, .14); color: var(--vcw-sky); }
.vcw-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
.vcw-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vcw-orange); }
.vcw-pill.is-muted { color: var(--vcw-muted); }
/* Last purge line */
.vcw-last { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 10px 12px; border-radius: 10px; background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); }
.vcw-last .vcw-label { margin-right: 4px; }
.vcw-time { font: 500 12px var(--vcw-mono); color: var(--vcw-muted); }
.vcw-path { flex-basis: 100%; font: 12px var(--vcw-mono); color: #cdd4ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Availability strip */
.vcw-strip-h { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; }
.vcw-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 1px; height: 28px; align-items: end; margin-top: -4px; }
.vcw-bars i { display: block; height: 100%; border-radius: 1px; background: var(--vcw-ok); opacity: .85; }
.vcw-bars i.is-slow { background: var(--vcw-warn); }
.vcw-bars i.is-down { background: var(--vcw-bad); }
.vcw-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
.vcw-bars i:hover { opacity: 1; }
.vcw-axis { display: flex; justify-content: space-between; font: 10.5px var(--vcw-mono); color: var(--vcw-faint); margin-top: -8px; }
/* Number tiles */
.vcw-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }
.vcw-tile { background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); border-radius: 10px; padding: 9px 11px; min-width: 0; }
.vcw-tile .vcw-label { font-size: 10px; }
.vcw-val { font-size: 20px; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
.vcw-tile .vcw-sub { font-size: 11px; }
/* Hit rate split bar */
.vcw-split-bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: rgba(140, 156, 255, .12); margin-top: 8px; }
.vcw-split-bar i { display: block; height: 100%; }
.vcw-split-bar i.is-navy, .vcw-legend i.is-navy { background: var(--vcw-navy); }
.vcw-split-bar i.is-sky, .vcw-legend i.is-sky { background: var(--vcw-sky); }
.vcw-split-bar i.is-warn, .vcw-legend i.is-warn { background: var(--vcw-warn); }
.vcw-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px; font-size: 11px; color: var(--vcw-muted); font-variant-numeric: tabular-nums; }
.vcw-legend span { display: inline-flex; align-items: center; gap: 5px; }
.vcw-legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.vcw-note { margin: 0; font-size: 11px; color: var(--vcw-faint); }
.vcw-foot { margin-top: auto; padding-top: 2px; }
.vcw-link { color: var(--vcw-orange); font-weight: 700; font-size: 12.5px; text-decoration: none; }
.vcw-link:hover, .vcw-link:focus { color: var(--vcw-orange); text-decoration: underline; }
.vc-widget a:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
/* Info button with tooltip, top right (pure CSS: hover or keyboard focus). */
.vc-widget { position: relative; }
.vc-widget > .vcw-info + * { padding-right: 32px; }
.vcw-info-btn {
position: absolute; top: 12px; right: 14px; z-index: 6;
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
border-radius: 50%; border: 1px solid rgba(140, 156, 255, .28);
background: rgba(140, 156, 255, .1); color: var(--vcw-muted);
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
transition: color .15s, border-color .15s, background-color .15s;
}
.vcw-info:hover .vcw-info-btn, .vcw-info:focus-within .vcw-info-btn {
color: #fff; border-color: var(--vcw-orange); background: rgba(244, 121, 55, .22);
}
.vcw-info-btn:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
.vcw-tip {
position: absolute; top: 46px; right: 8px; z-index: 5; width: min(320px, calc(100% - 16px));
padding: 12px 14px; border-radius: 10px;
background: #1a2460; border: 1px solid var(--vcw-navy);
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
color: var(--vcw-text); font-size: 12.5px; font-weight: 400; line-height: 1.55; text-align: left;
opacity: 0; visibility: hidden; transform: translateY(-4px);
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
}
.vcw-tip::before {
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
background: #1a2460; border-left: 1px solid var(--vcw-navy); border-top: 1px solid var(--vcw-navy);
transform: rotate(45deg);
}
.vcw-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
.vcw-info:hover .vcw-tip, .vcw-info:focus-within .vcw-tip {
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
.vcw-pulse::after { animation: none; }
.vcw-tip, .vcw-info-btn { transition: none; }
}
@@ -0,0 +1,366 @@
/**
* VITEC SSR cockpit (module web_vitecssr).
* Draws the gauges, the availability strip, the traffic chart and the
* sparkline from the JSON the controller puts on #ssr-cockpit
* (SsrController::cockpitData), and keeps the live parts current by
* polling the vitec_ssr_health ajax route (GET /__health of the node):
* every 2 s while a warm run is active, every 15 s otherwise.
* Chart.js is the copy TYPO3 ships with EXT:dashboard.
*/
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js';
const root = document.getElementById('ssr-cockpit');
if (root) {
Chart.register(...registerables);
let data = {};
try {
data = JSON.parse(root.dataset.json || '{}');
} catch (e) {
data = {};
}
const C = 2 * Math.PI * 40;
const $ = (id) => document.getElementById(id);
const nf = new Intl.NumberFormat('en-GB');
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
const css = getComputedStyle(root);
const tok = (name) => css.getPropertyValue(name).trim();
const col = {
navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'),
orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'),
text: tok('--vc-text'), muted: tok('--vc-muted'),
};
const setRing = (el, frac) => {
if (!el) {
return;
}
el.style.strokeDasharray = String(C);
el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac))));
};
/* Hit rate gauge */
const hit = data.hitRate;
if (typeof hit === 'number') {
$('vc-hit').textContent = hit.toFixed(1);
requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100));
if (typeof data.hitRatePrev === 'number') {
const d = Math.round((hit - data.hitRatePrev) * 10) / 10;
const cls = d >= 0 ? 'is-up' : 'is-down';
$('vc-hit-trend').innerHTML = '<span class="' + cls + '">' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
+ ' pts</span> vs. the 24 h before';
}
} else {
$('vc-hit-trend').textContent = 'no traffic counters yet';
}
/* Response time */
if (data.msMedian !== null && data.msMedian !== undefined) {
$('vc-ms-median').textContent = data.msMedian + ' ms';
$('vc-ms-p95').textContent = data.msP95 + ' ms';
}
const drawSpark = () => {
const sp = (data.spark || []).map((p) => p[1]);
const el = $('vc-spark');
if (!el || sp.length < 2) {
return;
}
const w = 300;
const h = 46;
const max = Math.max(...sp) * 1.08;
const min = Math.min(...sp) * 0.85;
const span = Math.max(1, max - min);
const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]);
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const last = pts[pts.length - 1];
el.innerHTML = '<defs><linearGradient id="vc-sg" x1="0" y1="0" x2="0" y2="1">'
+ '<stop offset="0" stop-color="' + col.orange + '" stop-opacity=".45"/>'
+ '<stop offset="1" stop-color="' + col.orange + '" stop-opacity="0"/></linearGradient></defs>'
+ '<path d="' + d + ' L' + w + ' ' + h + ' L0 ' + h + 'Z" fill="url(#vc-sg)"/>'
+ '<path d="' + d + '" fill="none" stroke="' + col.orange + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
+ '<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="3.5" fill="' + col.orange
+ '" stroke="' + col.panel + '" stroke-width="2"/>';
};
drawSpark();
/* Cache fill */
const renderCache = (pages, mb) => {
const limit = num(data.cacheLimitMb);
if (typeof pages === 'number') {
$('vc-cache-pages').textContent = nf.format(pages);
}
if (typeof mb === 'number') {
$('vc-cache-mb').textContent = nf.format(mb);
if (limit > 0) {
setRing($('vc-ring-cache'), mb / limit);
$('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used';
}
}
};
requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb));
/* Client errors per hour */
const mini = $('vc-mini');
const eh = data.errHours || [];
const ehMax = Math.max(1, ...eh);
eh.forEach((v, i) => {
const b = document.createElement('i');
b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%';
if (v > 0 && v >= ehMax && v >= 3) {
b.className = 'is-hot';
}
b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's');
mini.appendChild(b);
});
/* Availability strip */
const strip = data.strip || { w: [], end: data.now, slot: 900 };
const bars = $('vc-bars');
const stripStart = strip.end - 96 * strip.slot;
(strip.w || []).forEach((w, i) => {
const b = document.createElement('i');
const t = stripStart + i * strip.slot;
let label = 'no sample';
if (w === null) {
b.className = 'is-none';
} else if (w.s === 2) {
b.className = 'is-down';
label = 'down';
} else if (w.s === 1) {
b.className = 'is-slow';
label = 'slow, ' + w.ms + ' ms';
} else {
label = 'ok, ' + w.ms + ' ms';
b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%';
}
b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label;
bars.appendChild(b);
});
const axis = $('vc-uptime-axis');
[0, 24, 48, 72, 96].forEach((i, k) => {
const s = document.createElement('span');
s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot);
axis.appendChild(s);
});
if (typeof data.uptimePct === 'number') {
const slow = num(data.slowWindows);
const down = num(data.downWindows);
$('vc-uptime-sum').innerHTML = '<b>' + data.uptimePct.toFixed(1) + ' %</b> up · ' + slow + ' slow window'
+ (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's');
} else {
$('vc-uptime-sum').textContent = 'no samples yet';
}
/* Traffic chart */
let chart = null;
const label = (ts, size) => {
const d = new Date(ts * 1000);
const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm;
};
const draw = (key) => {
const range = (data.range || {})[key];
if (!range) {
return;
}
const b = range.b;
const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0);
const h = sum('hit');
const st = sum('stale');
const m = sum('miss');
const total = h + st + m;
$('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–';
$('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–';
$('vc-st-err').textContent = nf.format(sum('errors'));
$('vc-st-purge').textContent = nf.format(sum('purges'));
const hasMs = b.some((x) => x.ms !== null);
$('vc-traffic-empty').hidden = total > 0 || hasMs;
const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss)));
const maxMs = Math.max(100, ...b.map((x) => num(x.ms)));
const tick = { color: col.faint, font: { size: 11 } };
const cfg = {
type: 'bar',
data: {
labels: b.map((x) => label(x.t, range.size)),
datasets: [
{ label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 },
{ label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 },
{ label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 },
{
type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2,
pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1,
},
{
type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false,
pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel,
yAxisID: 'y', order: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 600 },
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1,
titleColor: col.text, bodyColor: '#c4cbf0', padding: 10,
filter: (it) => it.raw !== null,
callbacks: {
label: (c) => {
if (c.dataset.label === 'Purge') {
const x = b[c.dataIndex];
return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : '');
}
if (c.dataset.label === 'Response time') {
return ' Response time: ' + c.raw + ' ms';
}
return ' ' + c.dataset.label + ': ' + nf.format(c.raw);
},
},
},
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } },
y: {
stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick,
border: { display: false }, title: { display: true, text: 'requests', color: col.faint },
},
y1: {
position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false },
ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false },
},
},
},
};
if (chart) {
chart.destroy();
}
chart = new Chart($('vc-traffic'), cfg);
};
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
// Draw once the stylesheet has laid out the grid, and follow the box
// afterwards: Chart.js alone kept the width it saw on first paint.
const box = $('vc-traffic').parentElement;
const start = () => {
draw('24h');
if (window.ResizeObserver) {
let size = box.clientWidth + 'x' + box.clientHeight;
new ResizeObserver(() => {
const now = box.clientWidth + 'x' + box.clientHeight;
if (chart && now !== size) {
size = now;
chart.resize();
}
}).observe(box);
}
};
if (document.readyState === 'complete') {
requestAnimationFrame(start);
} else {
window.addEventListener('load', () => requestAnimationFrame(start), { once: true });
}
root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => {
btn.addEventListener('click', () => {
root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false'));
draw(btn.dataset.range);
});
});
/* Warm cache + live status */
const warmBtn = $('ssr-warm-button');
const warmLabel = $('vc-warm-label');
const warmPct = $('vc-warm-pct');
const warmMeta = $('vc-warm-meta');
const warmPill = $('vc-warm-pill');
const warmRing = $('vc-ring-warm');
const pill = (cls, text) => {
warmPill.hidden = !text;
warmPill.className = 'vc-pill ' + cls;
warmPill.textContent = text || '';
};
const renderWarm = (w) => {
w = w || {};
const queued = num(w.queued);
const handled = num(w.done) + num(w.skipped) + num(w.failed);
const failed = num(w.failed);
const failedHtml = failed ? '<span class="vc-bad-text">' + failed + ' failed</span>' : '0 failed';
if (w.running) {
const f = queued > 0 ? handled / queued : 0;
setRing(warmRing, f);
warmPct.textContent = Math.round(f * 100) + ' %';
warmMeta.innerHTML = '<b>' + nf.format(handled) + '</b> of ' + nf.format(queued) + ' pages<br>'
+ num(w.skipped) + ' still fresh · ' + failedHtml + '<br>live, every 2 seconds';
pill('is-sky', 'running');
} else if (w.startedAt) {
setRing(warmRing, queued > 0 ? handled / queued : 1);
warmPct.textContent = failed || w.aborted ? '!' : '✓';
const started = new Date(w.startedAt);
const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null;
const took = secs === null ? '' : '<br>took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s');
warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
+ '<br><b>' + nf.format(num(w.done)) + '</b> of ' + nf.format(queued) + ' rendered · '
+ num(w.skipped) + ' still fresh · ' + failedHtml + took;
pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done'));
} else {
setRing(warmRing, 0);
warmPct.textContent = '–';
warmMeta.textContent = 'No run since the server started.';
pill('', '');
}
if (warmBtn) {
warmBtn.disabled = !!w.running;
warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache';
}
return !!w.running;
};
requestAnimationFrame(() => renderWarm(data.warming));
const renderOnline = (online) => {
const p = $('vc-pulse');
const t = $('vc-live-title');
if (!p || !t) {
return;
}
p.classList.toggle('is-down', !online);
t.innerHTML = online ? 'SSR <span class="vc-ok-text">online</span>' : 'SSR <span class="vc-bad-text">not reachable</span>';
};
let timer = null;
const schedule = (ms) => {
clearTimeout(timer);
timer = setTimeout(poll, ms);
};
const poll = () => {
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get()
.then((response) => response.resolve())
.then((res) => {
const h = res && res.health;
renderOnline(!!h);
let running = false;
if (h) {
running = renderWarm(h.warming);
renderCache(h.cachedPages, h.cacheMB);
if (typeof h.responseMs === 'number') {
$('vc-ms').textContent = h.responseMs;
}
}
schedule(running ? 2000 : 15000);
})
.catch(() => schedule(15000));
};
schedule(data.warming && data.warming.running ? 1000 : 15000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearTimeout(timer);
} else {
poll();
}
});
}
@@ -0,0 +1,27 @@
/**
* Context menu action "Purge from SSR cache" (SsrPurgeItemProvider).
* Posts the page uid to the vitec_ssr_purge_page ajax route and shows
* the outcome as a backend notification.
*/
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
import Notification from '@typo3/backend/notification.js';
class SsrContextMenuActions {
ssrPurge(table, uid) {
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_purge_page)
.post({ pageUid: uid })
.then(async (response) => {
const data = await response.resolve();
if (data.success) {
Notification.success('SSR cache purged', data.path + ' (' + data.message + ')', 5);
} else {
Notification.error('SSR purge failed', data.message || 'Unknown error', 8);
}
})
.catch(() => {
Notification.error('SSR purge failed', 'Request error - see purge protocol in the VITEC SSR module', 8);
});
}
}
export default new SsrContextMenuActions();
@@ -0,0 +1,42 @@
/**
* VITEC SSR module: TYPO3 modal confirmation for the destructive / heavy
* actions (Purge everything, Warm cache) instead of window.confirm.
* A form opts in with data-confirm="text" (+ data-confirm-title,
* data-confirm-ok, data-confirm-severity="danger|primary").
*/
import Modal from '@typo3/backend/modal.js';
import Severity from '@typo3/backend/severity.js';
document.querySelectorAll('form[data-confirm]').forEach((form) => {
form.addEventListener('submit', (event) => {
if (form.dataset.confirmed === '1') {
return;
}
event.preventDefault();
const danger = form.dataset.confirmSeverity === 'danger';
Modal.confirm(
form.dataset.confirmTitle || 'Please confirm',
form.dataset.confirm,
danger ? Severity.error : Severity.warning,
[
{
text: 'Cancel',
btnClass: 'btn-default',
name: 'cancel',
trigger: (e, modal) => modal.hideModal(),
},
{
text: form.dataset.confirmOk || 'Continue',
btnClass: danger ? 'btn-danger' : 'btn-primary',
name: 'ok',
active: true,
trigger: (e, modal) => {
modal.hideModal();
form.dataset.confirmed = '1';
form.requestSubmit();
},
},
],
);
});
});
+19
View File
@@ -20,6 +20,14 @@ $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['proc
// XClass BackendLayoutView so the page module always reflects pages.layout // XClass BackendLayoutView so the page module always reflects pages.layout
// without the editor having to set backend_layout manually. // without the editor having to set backend_layout manually.
// Content-block images as processed, responsive WebP instead of the raw
// upload (Lighthouse 2026-11-10: 4-13 MB hero originals). The headless
// content-blocks package instantiates its serializer via makeInstance, so
// XCLASS is the supported hook. See Classes/Headless/ProcessedFileReferenceToArray.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\Netzbewegung\NbHeadlessContentBlocks\DataProcessing\ToArray\FileReferenceToArray::class] = [
'className' => \Evomedien\Vitec\Headless\ProcessedFileReferenceToArray::class,
];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Backend\View\BackendLayoutView::class] = [ $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Backend\View\BackendLayoutView::class] = [
'className' => \Evomedien\Vitec\View\VitecBackendLayoutView::class, 'className' => \Evomedien\Vitec\View\VitecBackendLayoutView::class,
]; ];
@@ -260,3 +268,14 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
// Product content audit (/products/product-check): ?check=<product uid>
// switches the vitec_productcheck element to the detail payload.
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'check';
// SSR auto-purge: editors' changes evict the matching pages from the SSR
// node's full-page cache (queued per request, one call per save/import).
// Active only while the SSR flag is ON and vitec.ssr.autoPurge is enabled.
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['vitec_ssrpurge']
= \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class;
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['vitec_ssrpurge']
= \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class;
+80
View File
@@ -63,6 +63,7 @@ CREATE TABLE tx_vitec_domain_model_product (
resources int(11) DEFAULT '0' NOT NULL, resources int(11) DEFAULT '0' NOT NULL,
resources_layout varchar(20) DEFAULT '50-50' NOT NULL, resources_layout varchar(20) DEFAULT '50-50' NOT NULL,
datasheets int(11) DEFAULT '0' NOT NULL, datasheets int(11) DEFAULT '0' NOT NULL,
features int(11) DEFAULT '0' NOT NULL,
KEY parent (pid) KEY parent (pid)
); );
CREATE TABLE tx_vitec_domain_model_download ( CREATE TABLE tx_vitec_domain_model_download (
@@ -135,6 +136,23 @@ CREATE TABLE tx_vitec_product_resource (
KEY product (product) KEY product (product)
); );
CREATE TABLE tx_vitec_product_feature (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
tstamp int(11) DEFAULT '0' NOT NULL,
crdate int(11) DEFAULT '0' NOT NULL,
deleted tinyint(4) DEFAULT '0' NOT NULL,
hidden tinyint(4) DEFAULT '0' NOT NULL,
sorting int(11) DEFAULT '0' NOT NULL,
product int(11) unsigned DEFAULT '0' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
cardtext text,
icon int(11) unsigned DEFAULT '0' NOT NULL,
PRIMARY KEY (uid),
KEY parent (pid),
KEY product (product)
);
CREATE TABLE tx_vitec_domain_model_usecase ( CREATE TABLE tx_vitec_domain_model_usecase (
title varchar(255) DEFAULT '' NOT NULL, title varchar(255) DEFAULT '' NOT NULL,
slug varchar(255) DEFAULT '' NOT NULL, slug varchar(255) DEFAULT '' NOT NULL,
@@ -442,3 +460,65 @@ CREATE TABLE tx_vitec_domain_model_searchcard (
link varchar(1024) DEFAULT '' NOT NULL, link varchar(1024) DEFAULT '' NOT NULL,
link_label varchar(80) DEFAULT '' NOT NULL link_label varchar(80) DEFAULT '' NOT NULL
); );
#
# SSR purge protocol (auto-purge webhook + backend module, 2026-11-09)
#
CREATE TABLE tx_vitec_ssrpurgelog (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
crdate int(11) DEFAULT '0' NOT NULL,
trigger_source varchar(100) DEFAULT '' NOT NULL,
paths text,
success tinyint(1) DEFAULT '0' NOT NULL,
http_code int(11) DEFAULT '0' NOT NULL,
message varchar(255) DEFAULT '' NOT NULL,
duration_ms int(11) DEFAULT '0' NOT NULL,
PRIMARY KEY (uid),
KEY crdate (crdate)
);
#
# SSR health history (vitec:ssr-health via scheduler, dashboard widgets)
#
CREATE TABLE tx_vitec_ssrhealthlog (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
crdate int(11) DEFAULT '0' NOT NULL,
ok tinyint(1) DEFAULT '0' NOT NULL,
release_id varchar(100) DEFAULT '' NOT NULL,
cached_pages int(11) DEFAULT '0' NOT NULL,
cache_mb int(11) DEFAULT '0' NOT NULL,
response_ms int(11) DEFAULT '0' NOT NULL,
stat_hit int(11) DEFAULT '-1' NOT NULL,
stat_miss int(11) DEFAULT '-1' NOT NULL,
stat_stale int(11) DEFAULT '-1' NOT NULL,
stat_bypass int(11) DEFAULT '-1' NOT NULL,
stat_render_errors int(11) DEFAULT '-1' NOT NULL,
stat_backend_errors int(11) DEFAULT '-1' NOT NULL,
PRIMARY KEY (uid),
KEY crdate (crdate)
);
#
# Client error reports from the React frontend
# (POST /api/vitec/client-error, ClientErrorMiddleware; 30-day retention
# via vitec:client-error-cleanup; shown in the VITEC SSR module)
#
CREATE TABLE tx_vitec_clienterrorlog (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
crdate int(11) DEFAULT '0' NOT NULL,
url varchar(1024) DEFAULT '' NOT NULL,
message text,
stack text,
release varchar(100) DEFAULT '' NOT NULL,
user_agent varchar(500) DEFAULT '' NOT NULL,
ip varchar(64) DEFAULT '' NOT NULL,
PRIMARY KEY (uid),
KEY crdate (crdate)
);
CREATE TABLE tx_news_domain_model_news (
vitec_canonical_usecase int(11) unsigned DEFAULT '0' NOT NULL
);
+88 -2
View File
@@ -106,6 +106,19 @@
### Begin: Immutable processed images (SEO/Lighthouse 2026-11-10) ###
# Files below fileadmin/_processed_/ carry a content hash in their name and
# never change in place - a year of immutable caching is safe and removes the
# "uses-long-cache-ttl" findings (mod_expires below sets only one month).
<IfModule mod_headers.c>
<If "%{REQUEST_URI} =~ m#^/fileadmin/_processed_/#">
Header set Cache-Control "max-age=31536000, immutable"
</If>
</IfModule>
### End: Immutable processed images ###
### Begin: Browser caching of resource files ### ### Begin: Browser caching of resource files ###
# This affects Frontend and Backend and increases performance. # This affects Frontend and Backend and increases performance.
@@ -289,6 +302,12 @@ AddDefaultCharset utf-8
RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$ RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$
RewriteRule ^.*$ - [E=CWD:%2] RewriteRule ^.*$ - [E=CWD:%2]
# robots.txt per host: the Sitemap directive must be an absolute URL on the
# crawled host, so dev serves its own file (Seobility found "0 sitemaps"
# because robots.txt pointed at www.vitec.com). SEO audit 2026-11-10.
RewriteCond %{HTTP_HOST} ^dev\.vitec\.com$ [NC]
RewriteRule ^robots\.txt$ robots-dev.txt [L]
# Rules to set ApplicationContext based on hostname # Rules to set ApplicationContext based on hostname
#RewriteCond %{HTTP_HOST} ^dev\.example\.com$ #RewriteCond %{HTTP_HOST} ^dev\.example\.com$
#RewriteRule .? - [E=TYPO3_CONTEXT:Development] #RewriteRule .? - [E=TYPO3_CONTEXT:Development]
@@ -345,6 +364,46 @@ AddDefaultCharset utf-8
RewriteCond %{QUERY_STRING} __typo3_install RewriteCond %{QUERY_STRING} __typo3_install
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L] RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L]
# --- VITEC SSR routing (flag-gated) -----------------------------------
# Active ONLY while the flag file below exists. Mirrors the SPA
# fallback's exclusions but PROXIES page requests to the SSR node on
# the Solr VPS instead of serving the React shell (mod_proxy incl.
# https target verified on this host 2026-11-09, see Nextcloud
# vitec-solr/ssr/README-SSR-Setup.md). The flag is written/removed by
# EXT:vitec (Site Set switch "vitec.ssr.enabled" -> SsrFlagService,
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
# Emergency off without TYPO3: delete the flag file.
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L,E=SSRPROXY:1]
# mod_dir turns "/" into "/index.html" via DirectoryIndex subrequest
# before the rule above can proxy it (found 2026-11-09 evening: the
# SSR node logged GET /index.html -> /_json/index.html -> 404).
# Catch that spelling and send it to the node as the homepage:
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^index\.html$ https://vitec-ssr.evomedien.de/ [P,L,E=SSRPROXY:1]
# Flag ON: the hashed JS/CSS files come from the VPS release too
# (0054-FE 5.1 / 0055-BE question 1). No !-f guard on purpose: a
# same-named file from an older local SPA build must not win, the
# SSR HTML references the hashes of the VPS release.
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^_frontend/assets/(.*)$ https://vitec-ssr.evomedien.de/_frontend/assets/$1 [P,L,E=SSRPROXY:1]
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteCond %{REQUEST_URI} !^/_json(/|$)
RewriteCond %{REQUEST_URI} !^/typo3(/|$)
RewriteCond %{REQUEST_URI} !^/download/
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/_frontend/
RewriteCond %{REQUEST_URI} !^/sitemap
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L,E=SSRPROXY:1]
# The SSR node derives absolute URLs from X-Forwarded-Host/-Proto;
# mod_proxy sends -Host on its own, -Proto needs a hand (0054-FE 5.2):
<IfModule mod_headers.c>
RequestHeader set X-Forwarded-Proto https env=SSRPROXY
</IfModule>
# ----------------------------------------------------------------------
# The root path needs its own rule: for "/" REQUEST_FILENAME is the # The root path needs its own rule: for "/" REQUEST_FILENAME is the
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex # docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
# would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/. # would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/.
@@ -416,9 +475,36 @@ Options -MultiViews
Header set X-Content-Type-Options "nosniff" Header set X-Content-Type-Options "nosniff"
</IfModule> </IfModule>
# ETag removal # TYPO3 cache-tag/debug headers: internal ids, and on news pages >16 KB,
# which overflowed the SSR node's default header limit (0054-FE 5.4) -
# never send them to clients.
<IfModule mod_headers.c> <IfModule mod_headers.c>
Header unset ETag Header unset X-TYPO3-Cache-Tags
Header unset X-TYPO3-Cache-Tags-1
Header unset X-TYPO3-Cache-Tags-2
Header unset X-TYPO3-Cache-Tags-3
Header unset X-TYPO3-Debug-Cache
Header unset X-TYPO3-Cache-Lifetime
Header unset X-TYPO3-Parsetime
# Depending on the PHP SAPI these can sit in the "always" table:
Header always unset X-TYPO3-Cache-Tags
Header always unset X-TYPO3-Cache-Tags-1
Header always unset X-TYPO3-Cache-Tags-2
Header always unset X-TYPO3-Cache-Tags-3
Header always unset X-TYPO3-Debug-Cache
Header always unset X-TYPO3-Cache-Lifetime
Header always unset X-TYPO3-Parsetime
</IfModule>
# ETag removal - except for the /_json API: there TYPO3 (JsonCacheHeaders
# middleware) sends a content ETag so browsers can revalidate with 304
# (tickets 0011-FE / 0055-BE).
<IfModule mod_headers.c>
SetEnvIf Request_URI "^/_json($|/)" KEEP_ETAG=1
# After the mod_rewrite internal redirect to index.php the variable
# is renamed REDIRECT_KEEP_ETAG - carry it back over:
SetEnvIf REDIRECT_KEEP_ETAG 1 KEEP_ETAG=1
Header unset ETag env=!KEEP_ETAG
</IfModule> </IfModule>
FileETag None FileETag None
+8
View File
@@ -3,6 +3,14 @@ RewriteBase /_frontend/
RewriteRule ^index\.html$ - [L] RewriteRule ^index\.html$ - [L]
# SSR flag ON: hashed assets are served from the VPS release - its hashes
# must win over any local build, and the fallback below must never answer
# an asset request with index.html (the browser would get HTML as JS).
# This directory's .htaccess overrides the root one for /_frontend/*, so
# the rule has to live HERE, not in public/.htaccess (2026-11-09).
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^assets/(.*)$ https://vitec-ssr.evomedien.de/_frontend/assets/$1 [P,L]
RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L] RewriteRule ^ - [L]
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more