Files
KantSites/src/Controller/PageController.php
T
kantarciandClaude Opus 5.5 6fa303f901 Save consent without reloading the page
consent.js posts the consent form in the background, the server answers
204 with the cookie, and the script swaps in the embeds and the legal line
from a fresh copy of the page. Embed hosts are in frame-src from the start,
the content address still only after consent. Without JavaScript the form
keeps working with a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00

1218 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controller;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Translations;
use App\Content\Turnstile;
use App\Plugin\PluginRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
{
}
/**
* Die Sprache dieser Anfrage, wenn es nicht die Hauptsprache ist: Kennung und die Zuordnung
* Pfad der Hauptsprache => übersetzter Pfad (ADR 111). Null für die Hauptsprache.
*
* @var array{locale: string, map: array<string, string>}|null
*/
private ?array $translation = null;
/** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */
private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px';
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response
{
$this->translation = null;
// Eine weitere Sprache steht vorn in der Adresse: /en ist ihre Startseite, /en/services
// eine übersetzte Seite. Ohne weitere Sprache gibt es diesen Weg gar nicht.
$segments = explode('/', $path, 2);
if ('' !== $path && $this->languages->isExtra($segments[0])) {
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
}
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
return $this->notFound($request);
}
$home = $this->settings->homePath();
$path = '' === $path ? $home : $path;
// One canonical address for the root page: its own path redirects to "/".
// Ausgenommen der Weg aus dem Formularversand: der zeigt dieselbe Seite mit Meldungen,
// und eine Umleitung wuerde genau die Meldungen verwerfen.
if ($path === $home && '/' !== $request->getPathInfo() && null === $request->attributes->get('kant_form_state')) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
if (null === $page) {
// Eine Seite geht vor; eine eigene Adresse eines Plugins kommt vor Weiterleitung und 404.
$route = $this->plugins->route('/'.$path);
if (null !== $route) {
return $this->pluginRoute($request, $route);
}
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
return $this->notFound($request, null !== $redirect ? 410 : 404);
}
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
return $this->notFound($request);
}
return $this->renderPage($request, $page, $path);
}
/**
* Eine Seite in einer weiteren Sprache. Gefunden wird sie über ihre übersetzte Adresse; ihre
* Startseite antwortet unter /en und nicht zusätzlich unter /en/home.
*/
private function translatedPage(Request $request, string $locale, string $rest): Response
{
$map = $this->pages->translationMap($locale);
// Schon hier: auch eine 404 unter /en/ antwortet auf Englisch.
$this->translation = ['locale' => $locale, 'map' => $map];
$homeTranslated = $map[$this->settings->homePath()] ?? null;
if ('' !== $rest && $rest === $homeTranslated && null === $request->attributes->get('kant_form_state')) {
return $this->shell($this->redirect('/'.$locale, 301));
}
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
$this->translation = ['locale' => $locale, 'map' => $map];
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
}
$target = '' === $rest ? $homeTranslated : $rest;
$page = null === $target ? null : $this->pages->findPublishedTranslation($target, $locale);
if (null === $page) {
$redirect = '' === $rest ? null : $this->redirects->find($locale.'/'.$rest);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
return $this->notFound($request, null !== $redirect ? 410 : 404);
}
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
return $this->notFound($request);
}
return $this->renderPage($request, $page, $page['path']);
}
/**
* Kopf, Fuß und Sektionen in der Sprache dieser Anfrage: übersetzt, wo es Übersetzungen gibt,
* und ohne die Übersetzungen selbst in der Hauptsprache.
*/
private function localise(array $document): array
{
return null === $this->translation ? Translations::strip($document) : Translations::apply($document, $this->translation['locale']);
}
/**
* Die öffentliche Adresse eines Pfades der Hauptsprache in der Sprache dieser Anfrage - oder
* unverändert, wenn die Seite dort nicht übersetzt ist.
*/
private function localHref(string $href): string
{
if (null === $this->translation || !str_starts_with($href, '/') || str_starts_with($href, '//')) {
return $href;
}
$suffix = '';
if (false !== ($cut = strcspn($href, '?#')) && $cut < strlen($href)) {
$suffix = substr($href, $cut);
$href = substr($href, 0, $cut);
}
$path = trim($href, '/');
$home = $this->settings->homePath();
$locale = $this->translation['locale'];
if ('' === $path || $path === $home) {
return isset($this->translation['map'][$home]) ? '/'.$locale.$suffix : $href.$suffix;
}
if ('beitraege' === $path || str_starts_with($path, 'kategorie/') || str_starts_with($path, 'schlagwort/')) {
return '/'.$locale.'/'.$path.$suffix;
}
return isset($this->translation['map'][$path]) ? '/'.$locale.'/'.$this->translation['map'][$path].$suffix : $href.$suffix;
}
/**
* Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf
* deren übersetzte Fassung - in Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen.
* Ein Link auf eine nicht übersetzte Seite bleibt, wie er ist. Ausgenommen sind die Links
* der Sprachwahl, die mit hreflang bewusst auf eine bestimmte Sprache zeigen.
*/
private function localiseLinks(string $html): string
{
if (null === $this->translation) {
return $html;
}
return preg_replace_callback('/<a\b[^>]*>/i', function (array $tag): string {
if (str_contains($tag[0], 'hreflang=')) {
return $tag[0];
}
return preg_replace_callback('/\bhref="(\/[^"]*)"/', fn (array $href): string => 'href="'.$this->localHref($href[1]).'"', $tag[0]) ?? $tag[0];
}, $html) ?? $html;
}
/**
* Unter welchen Adressen diese Seite in welcher Sprache steht - für hreflang und die
* Sprachwahl. Leer, solange die Website nur eine Sprache hat.
*
* @return list<array{locale: string, native: string, href: string, url: string, current: bool}>
*/
private function alternates(array $page, int $status): array
{
$extra = $this->languages->extra();
if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) {
return [];
}
$origin = rtrim($this->siteOrigin, '/');
$home = $this->settings->homePath();
$current = $this->translation['locale'] ?? $this->languages->main();
$main = $this->languages->main();
$list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]];
$translated = $this->pages->translatedPaths($page['id']);
foreach ($extra as $locale) {
if (isset($translated[$locale])) {
$list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]];
}
}
if (count($list) < 2) {
return [];
}
return array_map(fn (array $entry): array => [
...$entry,
'native' => $this->languages->describe($entry['locale'])['native'],
'url' => $origin.$entry['href'],
'current' => $entry['locale'] === $current,
], $list);
}
/**
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
*/
private function pluginRoute(Request $request, array $route): Response
{
$query = [];
foreach ($request->query->all() as $key => $value) {
if (is_string($value) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,40}$/D', (string) $key)) {
$query[$key] = mb_substr($value, 0, 500);
}
}
$response = $this->render($route['template'], ['settings' => $this->plugins->settingsOf($route['plugin']), 'query' => $query, 'route' => $route]);
$response->headers->set('Content-Type', $route['contentType']);
if ('html' !== $route['type']) {
$response->headers->set('X-Robots-Tag', 'noindex');
}
return $this->shell($response);
}
public function notFound(Request $request, int $status = 404): Response
{
$page = $this->notFoundPage->page($status);
$locale = $this->translation['locale'] ?? null;
// Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die
// mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111).
if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) {
$page['document'] = Translations::apply($page['document'], $locale);
} else {
$page['document'] = Translations::strip($page['document']);
if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) {
$page['document'] = $this->translateFixedTexts($page['document'], $locale);
}
}
if (null !== $locale) {
$page['locale'] = $locale;
}
return $this->renderPage($request, $page, '', $status);
}
/** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */
private function translateFixedTexts(array $document, string $locale): array
{
$t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text;
foreach (['title', 'description'] as $field) {
$document[$field] = $t((string) ($document[$field] ?? ''));
}
$walk = function (array $blocks) use (&$walk, $t): array {
foreach ($blocks as $index => $block) {
if (is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $t($block['props']['text']);
}
$blocks[$index]['children'] = $walk($block['children'] ?? []);
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
/** Published pages and error content share theme, areas, block rendering and CSP. */
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
{
$styles = '';
// The page decides which header and footer it gets - including none.
$areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page));
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = array_map($this->localise(...), $this->sections($page['document'], $areas));
// Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension).
$request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main());
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
$pristine = $sections;
$seen = [];
foreach (['header', 'footer'] as $kind) {
if ('header' === $kind && isset($areas['header'])) {
$areas['header'] = $this->partials->expandRepeatedLinks($areas['header'], $sections, $seen, $pristine);
}
if ('header' === $kind) {
$page['document'] = $this->partials->expandRepeatedLinks($page['document'], $sections, $seen, $pristine);
}
if ('footer' === $kind && isset($areas['footer'])) {
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
}
}
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
$pluginAssets = $this->plugins->assetsFor($documents);
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
// Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext.
// Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den
// jemand in einen formatierten Absatz geschrieben hat.
$needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: ''));
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
$forms = $this->formStates($page, $documents, $request->query->getString('gesendet'), $request);
// Schritte, Bedingungen und Zusammenfassung brauchen das eine Formularskript - nur dort,
// wo ein solches Formular steht.
$needsFormScript = (bool) array_filter($forms, fn (array $state): bool => null !== $state['form'] && $this->forms->needsScript($state['form']));
$styled = [];
foreach ($forms as $state) {
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
$styled[$state['form']['slug']] = true;
$styles .= $this->forms->css($state['form'], $theme);
}
}
// Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die
// Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com:
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
$areaScripts = [];
foreach ($areas as $kind => $area) {
if (isset($this->areaIds[$kind]) && [] !== $this->code->scriptsOf($area)) {
$areaScripts[] = '/assets/bereich/'.$this->areaIds[$kind].'.js';
}
}
$postLists = $this->postLists($documents, max(1, $request->query->getInt('seite', 1)));
// Eine blätternde Liste macht die Seite zu einer Folge von Seiten: die Canonical nennt die
// Seitenzahl, prev und next verbinden die Folge - sonst hielte eine Suchmaschine Seite 3
// für ein Duplikat von Seite 1.
$blaettern = null;
foreach ($postLists as $liste) {
if (isset($liste['pages']) && $liste['pages'] > 1) {
$blaettern = $liste;
break;
}
}
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath()));
$alternates = $this->alternates($page, $status);
// Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
$request->attributes->set('kant_alternates', $alternates);
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
// Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt
// consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das
// Formular mit Weiterleitung.
$embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections));
$needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request);
$response = $this->render('page.html.twig', [
'needsConsent' => $needsConsent,
'page' => $page,
'canonical' => $canonical,
'alternates' => $alternates,
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
'navigation' => $this->navigation($path),
'postLists' => $postLists,
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null,
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [],
'needsTabs' => $needsTabs,
'needsFormScript' => $needsFormScript,
'pluginStyles' => $pluginAssets['styles'],
'pluginScripts' => $pluginAssets['scripts'],
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
'needsScrolled' => $needsScrolled,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $needsZoom,
// Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein
// gewoehnlicher Link.
'needsDocument' => $needsDocument,
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'areaScripts' => $areaScripts,
'forms' => $forms,
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']),
// Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der
// Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die
// LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen.
'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null,
'pageStyles' => $styles,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
], new Response(status: $status));
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
$frames = $embeds['hosts'];
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
if ($status >= 400) {
$response->headers->set('X-Robots-Tag', 'noindex, follow');
}
if (null !== $this->translation) {
$response->setContent($this->localiseLinks((string) $response->getContent()));
}
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
}
/**
* Every public response carries the same headers. Scripting stays forbidden unless the page
* actually contains a tab group: only then may the browser load the one enhancement script,
* and only from this origin. Inline script is never allowed.
*
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*
* @param list<string> $frameHosts Hosts of the embeds on this page, also of those still
* waiting for consent (ADR 115: consent.js inserts them
* without a reload, and a policy cannot change afterwards)
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
*/
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
{
$styleSources = ["'self'"];
if ('' !== $styles) {
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
}
// Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein
// fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage.
$fontSources = ["'self'"];
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Diese Website braucht weder Kamera noch Mikrofon, Standort oder Bewegungssensoren. Das
// hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer
// eingebetteten fremden Seite - die Möglichkeit, danach zu fragen.
$response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn
// oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden.
$scriptSources = $allowScript ? ["'self'"] : ["'none'"];
if (in_array('challenges.cloudflare.com', $frameHosts, true)) {
$scriptSources = ["'self'", Turnstile::HOST];
}
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
implode(' ', $scriptSources),
implode(' ', $styleSources),
implode(' ', $fontSources),
// Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo
// eine Seite auf ein PDF verlinkt, und nur von hier.
[] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]),
));
return $response;
}
/**
* Der Zustand jedes Formulars auf dieser Seite: die Definition, ob diese Anfrage es gerade
* abgeschickt hat, und ein signierter Zeitstempel, an dem der Versand einen Menschen von
* einem Skript unterscheidet.
*
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
* laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
*/
private function formStates(array $page, array $documents, string $sentSlug, ?Request $request = null): array
{
$issued = time();
$states = [];
$uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$form = $this->forms->findBySlug($slug);
// In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111).
if (null !== $form && null !== $this->translation) {
$form = FormRepository::localise($form, $this->translation['locale']);
}
$states[$block['id']] = [
'form' => $form,
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
'optionImages' => null === $form ? [] : $this->optionImages($form),
'sent' => '' !== $sentSlug && $sentSlug === $slug,
'errors' => [], 'values' => [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
];
}
$walk($block['children'] ?? []);
}
};
// Formulare stehen auch im Kopf, im Fuss oder in einer verknuepften Sektion - ein
// Kontaktformular im Fuss jeder Seite ist der haeufigste Fall.
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
// Die Meldungen aus dem Formularversand gelten jedem Block, der dieses Formular zeigt.
if (isset($uebergeben['slug'])) {
foreach ($states as $blockId => $state) {
if (($state['form']['slug'] ?? null) === $uebergeben['slug']) {
$states[$blockId] = [...$state, 'errors' => $uebergeben['errors'], 'values' => $uebergeben['values'], 'sent' => false];
}
}
}
return $states;
}
/**
* Die Bilder der Optionen eines Formulars: Kennung => kleinste Fassung mit Massen. Ein Bild,
* das die Mediathek nicht mehr kennt, fehlt hier - die Option zeigt dann ihren Text.
*
* @return array<string, array{src: string, width: int, height: int}>
*/
private function optionImages(array $form): array
{
$ids = [];
foreach ($form['fields'] as $field) {
$ids = [...$ids, ...array_values($field['images'] ?? [])];
}
$images = [];
foreach ($this->media->findMany($ids) as $id => $item) {
$variant = $item['variants'][0] ?? null;
if (null !== $variant) {
$images[$id] = ['src' => $variant['src'], 'width' => (int) $variant['width'], 'height' => (int) $variant['height']];
}
}
return $images;
}
/**
* The trail from the home page down to this one, resolved from the stored hierarchy rather
* than from content, so it cannot claim a page that does not exist.
*
* @return list<array{name: string, href: string}>
*/
/** @return list<array<string, mixed>> */
private function faqEntries(array $blocks): array
{
$entries = [];
foreach ($blocks as $block) {
if ('accordion' === ($block['type'] ?? null) && true === ($block['props']['faq'] ?? false)) {
foreach ($block['children'] ?? [] as $item) {
$texts = array_filter($item['children'] ?? [], static fn (array $child): bool => 'text' === ($child['type'] ?? null));
$answer = trim(implode("\n\n", array_map(
static fn (array $child): string => trim(html_entity_decode(strip_tags(preg_replace('#<br\s*/?>|</p>#i', "\n", (string) ($child['props']['text'] ?? '')) ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')),
$texts,
)));
if ('' !== $answer && '' !== trim((string) ($item['props']['title'] ?? ''))) {
$entries[] = ['@type' => 'Question', 'name' => $item['props']['title'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer]];
}
}
}
$entries = [...$entries, ...$this->faqEntries($block['children'] ?? [])];
}
return $entries;
}
private function breadcrumbs(array $page): array
{
if ($page['path'] === $this->settings->homePath()) {
return [];
}
// Ein Archiv hat keine Seitenhierarchie: die Spur ist „Beiträge“, dann die Kategorie.
if ('archive' === ($page['type'] ?? null)) {
$trail = [['name' => 'Beiträge', 'href' => '/beitraege']];
if ('beitraege' !== $page['path']) {
$trail[] = ['name' => $page['document']['title'], 'href' => '/'.$page['path']];
}
return $trail;
}
$trail = [];
$prefix = '';
foreach (explode('/', (string) $page['path']) as $segment) {
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
// In einer weiteren Sprache heißt jede Stufe so, wie sie dort heißt - sofern es sie
// dort gibt. Sonst bleibt sie in der Hauptsprache stehen, mit ihrer Adresse.
$translated = null === $this->translation || !isset($this->translation['map'][$prefix]) ? null : $this->pages->findPublishedTranslation($this->translation['map'][$prefix], $this->translation['locale']);
$ancestor = $translated ?? $this->pages->findPublished($prefix);
if (null !== $ancestor) {
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => $this->localHref('/'.$prefix)];
}
}
return $trail;
}
/**
* JSON-LD built from values that are visible on the page or maintained in the settings.
* Nothing is invented here; a field without a maintained value is left out.
*
* @return list<array>
*/
/**
* Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet.
*
* Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht
* zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der
* Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash.
*/
private function scrollTopStyles(array $settings, array $theme): string
{
if (!($settings['scrollTop'] ?? false)) {
return '';
}
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
'eckig' => '0',
'weich' => '12px',
default => '999px',
});
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
$wert = Theme::cssColor((string) ($settings[$feld] ?? ''), $theme);
if (null !== $wert) {
$werte[] = $variable.':'.$wert;
}
}
return ':root{'.implode(';', $werte).'}';
}
/**
* Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz.
*
* Zuerst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild. Dieses
* Feld gab es schon, ausgegeben wurde es nie: eine Einstellung, die speichert und nichts tut.
* Die Adresse muss absolut sein, weil sie in fremden Diensten landet, und die Variante nicht
* die größte - 1600 px reichen jedem Vorschaubild, 2400 kostet nur Ladezeit beim Teilen.
*
* @return array{url: string, alt: string}|null
*/
private function socialImage(array $page): ?array
{
$id = (string) ($page['og_media_id'] ?? '') ?: (string) ($page['featured_media_id'] ?? '');
if ('' === $id) {
return null;
}
$item = $this->media->find($id);
if (null === $item || [] === $item['variants']) {
return null;
}
$passend = array_filter($item['variants'], static fn (array $variant): bool => $variant['width'] <= 1600);
$variant = end($passend) ?: $item['variants'][0];
return ['url' => rtrim($this->siteOrigin, '/').$variant['src'], 'alt' => (string) ($item['alt'] ?? '')];
}
private function structuredData(array $page): array
{
$origin = rtrim($this->siteOrigin, '/');
$blocks = [];
if ($page['path'] === $this->settings->homePath()) {
$organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']);
if (null !== $organisation) {
$blocks[] = $organisation;
}
}
if ('post' === $page['type']) {
$article = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']),
];
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
if ('' !== (string) $value && null !== $value) {
$article[$key] = 'datePublished' === $key ? str_replace(' ', 'T', (string) $value).'Z' : (string) $value;
}
}
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
}
$bild = $this->socialImage($page);
if (null !== $bild) {
$article['image'] = $bild['url'];
}
$blocks[] = $article;
}
// Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die
// Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier.
$fragen = $this->faqEntries($page['document']['blocks'] ?? []);
if ([] !== $fragen) {
$blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen];
}
$trail = $this->breadcrumbs($page);
if (count($trail) > 1) {
$blocks[] = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => array_map(
static fn (int $index, array $item): array => ['@type' => 'ListItem', 'position' => $index + 1, 'name' => $item['name'], 'item' => $origin.$item['href']],
array_keys($trail),
$trail,
),
];
}
return $blocks;
}
/** True when the stored document contains at least one block of that type. */
/** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */
private function hasZoom(array $document): bool
{
$gefunden = false;
$walk = function (array $blocks) use (&$walk, &$gefunden): void {
foreach ($blocks as $block) {
if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) {
$gefunden = true;
return;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $gefunden;
}
/** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */
private function hasScrolled(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
foreach ($block['style'] ?? [] as $values) {
if ([] !== ($values['scrolled'] ?? [])) {
return true;
}
}
if ($walk($block['children'] ?? [])) {
return true;
}
}
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasEntrance(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) {
return true;
}
}
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasType(array $document, string $type): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found, $type): void {
foreach ($blocks as $block) {
$found = $found || $type === ($block['type'] ?? null);
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
#[Route('/beitraege', name: 'posts', methods: ['GET'])]
#[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
public function archive(Request $request, ?string $slug = null): Response
{
$this->translation = null;
return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug);
}
private function renderArchive(Request $request, string $kind, ?string $slug): Response
{
$language = $this->translation['locale'] ?? null;
$t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text);
$term = null;
if (null !== $slug) {
$term = $this->posts->findTerm($kind, $slug);
if (null === $term) {
return $this->notFound($request);
}
$term['name'] = $this->posts->termName($term, $language);
}
$page = max(1, $request->query->getInt('seite', 1));
$result = $this->posts->published($page, $term['id'] ?? null, language: $language);
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
if (0 === $result['total']) {
return $this->notFound($request);
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$heading = null === $term ? $t('Beiträge') : $term['name'];
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
// eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
];
if (null !== $term) {
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('Schlagwort')], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
}
$document = [
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
'title' => $heading,
'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $term['name']),
'blocks' => [
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
]],
],
];
$seite = [
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(),
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
];
return $this->renderPage($request, $seite, $seite['path']);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(Request $request): Response
{
if (!$this->posts->anyPublished()) {
return $this->notFound($request);
}
$result = $this->posts->published(1, null, 20);
$response = $this->shell($this->render('feed.xml.twig', [
'posts' => $result['posts'],
'origin' => rtrim($this->siteOrigin, '/'),
]));
$response->headers->set('Content-Type', 'application/xml; charset=UTF-8');
return $response;
}
/**
* Collects the posts each post list block needs. Stored content never carries the posts
* themselves, so a saved document cannot claim a post it is not allowed to show.
*
* @return array<string, list<array>>
*/
private function postLists(array $documents, int $page = 1): array
{
$lists = [];
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
foreach ($blocks as $block) {
if ('postlist' === ($block['type'] ?? null)) {
$props = $block['props'] ?? [];
$term = null;
foreach (['category', 'tag'] as $kind) {
if ('' !== ($props[$kind] ?? '')) {
$term = $this->posts->findTerm($kind, $props[$kind]);
}
}
// Eine blätternde Liste nimmt die Seite aus der Anfrage, eine Auswahl
// immer die erste - sonst würden zwei Listen auf einer Seite einander
// umblättern.
$paginate = (bool) ($props['paginate'] ?? false);
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count'], language: $this->translation['locale'] ?? null);
$lists[$block['id']] = $paginate ? $result : $result['posts'];
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return $lists;
}
/**
* The own scripts of one published page, as a single file.
*
* A file instead of inline markup, so the policy stays hash based and every page without a
* script keeps `script-src 'none'`. Only published content is served: a draft script must not
* run for a visitor.
*/
#[Route('/assets/seite/{id}.js', name: 'page_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
public function pageScript(string $id): Response
{
$row = $this->pages->findPublishedById($id);
$script = null === $row ? '' : $this->code->compileScript($row['document']);
if ('' === $script) {
throw $this->createNotFoundException('Diese Seite hat kein eigenes Skript.');
}
$response = new Response($script, 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
/**
* The first image of the page, in the order it is rendered.
*
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
*/
/**
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
*
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
*
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
*
* @return list<array{src: string, media: string}>
*/
private function heroBackground(?array $header, array $document): array
{
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
// obere. Sonst der erste Abschnitt des Dokuments.
foreach ([$header, $document] as $kandidat) {
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
$id = $block['style']['desktop']['backgroundImage'] ?? null;
if (!is_string($id) || '' === $id) {
continue;
}
$item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) {
continue;
}
$verweise = [];
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
$verweise[] = [
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
'media' => $media,
];
}
return $verweise;
}
return [];
}
/**
* Das vorgezogene Bild als Vorabladung im Kopf der Seite.
*
* Die Angaben sind dieselben, die `blocks/block.html.twig` an das `img` schreibt - Quelle,
* Satz der Varianten und die Groessenangabe. Weicht eines davon ab, waehlt der Browser fuer
* die Vorabladung eine andere Variante als fuer das Bild und laedt zweimal.
*
* @return array{src: string, srcset: string, sizes: string}|null
*/
private function heroImage(array $document, ?string $eagerImage): ?array
{
if (null === $eagerImage) {
return null;
}
$block = $this->blockById($document['blocks'] ?? [], $eagerImage);
$props = $block['props'] ?? [];
if (null === $block || 'image' !== ($block['type'] ?? null) || '' === (string) ($props['src'] ?? '')) {
return null;
}
$varianten = [];
foreach (is_array($props['sources'] ?? null) ? $props['sources'] : [] as $quelle) {
if (is_array($quelle) && isset($quelle['src'], $quelle['width'])) {
$varianten[] = $quelle['src'].' '.$quelle['width'].'w';
}
}
$feste = (string) ($block['style']['desktop']['maxWidth'] ?? '');
return [
'src' => (string) $props['src'],
'srcset' => implode(', ', $varianten),
'sizes' => str_ends_with($feste, 'px') ? $feste : self::IMAGE_SIZES,
];
}
private function blockById(array $blocks, string $id): ?array
{
foreach ($blocks as $block) {
if ($id === ($block['id'] ?? null)) {
return $block;
}
$inside = $this->blockById($block['children'] ?? [], $id);
if (null !== $inside) {
return $inside;
}
}
return null;
}
private function firstImage(array ...$documents): ?string
{
foreach ($documents as $document) {
$found = $this->findImage($document['blocks'] ?? []);
if (null !== $found) {
return $found;
}
}
return null;
}
private function findImage(array $blocks): ?string
{
foreach ($blocks as $block) {
if ('image' === ($block['type'] ?? null)) {
return $block['id'];
}
$inside = $this->findImage($block['children'] ?? []);
if (null !== $inside) {
return $inside;
}
}
return null;
}
/**
* The hosts of all embeds on this page, and whether one of them still waits for consent.
*
* Seit ADR 115 steht der Host auch dann in frame-src, wenn die Gruppe noch nicht erlaubt ist:
* Die Richtlinie einer Seite steht beim Laden fest, und consent.js setzt den Rahmen nach der
* Einwilligung ohne Neuladen ein. Die Erlaubnis lädt nichts; die Adresse des Inhalts steht
* weiterhin erst nach der Einwilligung im HTML.
*
* @return array{hosts: list<string>, blocked: bool}
*/
private function embedState(Request $request, array ...$documents): array
{
$hosts = [];
$blocked = false;
$walk = function (array $blocks) use (&$walk, &$hosts, &$blocked, $request): void {
foreach ($blocks as $block) {
if ('embed' === ($block['type'] ?? null) && '' !== ($block['props']['url'] ?? '')) {
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
if (is_string($host)) {
$hosts[$host] = true;
}
$blocked = $blocked || !$this->consent->granted($request, $block['props']['group'] ?? '');
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return ['hosts' => array_keys($hosts), 'blocked' => $blocked];
}
/**
* The header and footer documents. Absent areas simply render nothing, so a site without a
* header is a valid site rather than a broken one.
*
* @return array<string, array>
*/
/** Die Kennungen der Bereiche dieser Antwort, fuer ihre Skriptdateien. @var array<string, string> */
private array $areaIds = [];
private function areas(?array $page = null): array
{
$areas = [];
$this->areaIds = [];
foreach (['header', 'footer'] as $kind) {
// A page may ask for another area or for none at all: a landing page without a
// navigation is a normal wish, and it must not need a second header document that
// happens to be empty.
$choice = $page[$kind.'_area'] ?? null;
if ('none' === $choice) {
continue;
}
$partial = $this->partials->findByKind($kind, is_string($choice) && '' !== $choice ? $choice : 'default');
// A page pointing at an area that no longer exists falls back to the standard one
// rather than losing its header without saying so.
$partial ??= $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
$this->areaIds[$kind] = (string) $partial['id'];
}
}
return $areas;
}
/**
* Die eigenen Skripte eines Bereichs, als Datei - dasselbe Muster wie bei einer Seite. Vorher
* wurde ein Skript im Kopf oder Fuss gespeichert, geprueft und nie ausgefuehrt.
*/
#[Route('/assets/bereich/{id}.js', name: 'area_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
public function areaScript(string $id): Response
{
$partial = $this->partials->find($id);
if (null === $partial || [] === $this->code->scriptsOf($partial['document'])) {
throw $this->createNotFoundException('Kein Skript.');
}
$response = new Response($this->code->compileScript($partial['document']), 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* Resolves every linked section a page or one of its areas refers to, including sections that
* link further sections. A missing section is left out; the renderer shows a placeholder only
* in the editor, so a visitor never sees a gap explained.
*
* @return array<string, array>
*/
private function sections(array $document, array $areas): array
{
$resolved = [];
$pending = $this->partials->referencedSlugs($document);
foreach ($areas as $area) {
$pending = [...$pending, ...$this->partials->referencedSlugs($area)];
}
for ($depth = 0; $depth < PartialRepository::MAX_NESTING && [] !== $pending; ++$depth) {
$next = [];
foreach (array_unique($pending) as $slug) {
if (isset($resolved[$slug])) {
continue;
}
$section = $this->partials->resolveSection($slug);
if (null === $section) {
continue;
}
$resolved[$slug] = $section['document'];
$next = [...$next, ...$this->partials->referencedSlugs($section['document'])];
}
$pending = $next;
}
return $resolved;
}
/**
* @return array<string, list<array>>
*/
private function navigation(string $currentPath): array
{
$links = [];
foreach ($this->menus->all() as $slot => $menu) {
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath, language: $this->translation['locale'] ?? null);
}
return $links;
}
}