Same-page anchors kept the details menu open. scrolled.js closes it on any link click and loads only where a collapsible menu holds such links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1269 lines
62 KiB
PHP
1269 lines
62 KiB
PHP
<?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.
|
||
// Dasselbe Skript schließt das aufgeklappte Menü nach einer Wahl (ADR 119) - gebraucht nur,
|
||
// wenn ein aufklappbares Menü auf ein Sprungziel dieser Seite zeigt: Ein Link auf eine
|
||
// andere Seite lädt neu (die Seiten senden no-store, also stellt „Zurück“ kein offenes
|
||
// Menü aus dem Seiten-Cache her). So bleibt eine Seite ohne solche Sprünge ohne Skript.
|
||
$navigation = $this->navigation($path);
|
||
$here = array_unique([rtrim($request->getPathInfo(), '/') ?: '/', $this->settings->publicPath($path, $this->settings->homePath())]);
|
||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false)
|
||
|| [] !== array_filter($this->collapsibleMenuSlots($documents), fn (string $slot): bool => $this->jumpsWithin($navigation[$slot] ?? [], $here));
|
||
$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' => $navigation,
|
||
'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' => FormRepository::stamp($slug, $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). */
|
||
/**
|
||
* Die Menüplätze (header, footer …) der Menüs, die als Knopf aufklappen: Burger oder waagerecht
|
||
* mit Zusammenklappen.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function collapsibleMenuSlots(array $documents): array
|
||
{
|
||
$slots = [];
|
||
$walk = function (array $blocks) use (&$walk, &$slots): void {
|
||
foreach ($blocks as $block) {
|
||
if ('menu' === ($block['type'] ?? null)) {
|
||
$layout = $block['props']['layout'] ?? 'horizontal';
|
||
if ('burger' === $layout || ('horizontal' === $layout && 'never' !== ($block['props']['collapse'] ?? 'tablet'))) {
|
||
$slots[] = (string) ($block['props']['slot'] ?? 'header');
|
||
}
|
||
}
|
||
$walk($block['children'] ?? []);
|
||
}
|
||
};
|
||
foreach ($documents as $document) {
|
||
$walk($document['blocks'] ?? []);
|
||
}
|
||
|
||
return array_values(array_unique($slots));
|
||
}
|
||
|
||
/** Ob ein Link des Menüs zu einem Sprungziel auf dieser Seite führt (#kontakt, /#kontakt auf der Startseite). @param list<string> $here */
|
||
private function jumpsWithin(array $links, array $here): bool
|
||
{
|
||
foreach ($links as $link) {
|
||
$href = (string) ($link['href'] ?? '');
|
||
$hash = strpos($href, '#');
|
||
if (false !== $hash && (0 === $hash || \in_array(rtrim(substr($href, 0, $hash), '/') ?: '/', $here, true))) {
|
||
return true;
|
||
}
|
||
if ($this->jumpsWithin($link['children'] ?? [], $here)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|