Cards, quotes and call-to-action bands side by side in a row are equal in height again: the stacking margin meant for a column no longer applies inside a row. The header can carry a second set of colours for the scrolled page, the Flatsome transparent header: a "scrolled" state next to "hover", limited to colours, shadow and opacity, marked by a small script only where it is used, plus a setting that lets the header float over the first section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
761 lines
34 KiB
PHP
761 lines
34 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Content\Consent;
|
||
use App\Content\CustomCode;
|
||
use App\Content\FontLibrary;
|
||
use App\Content\FormRepository;
|
||
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\Turnstile;
|
||
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)
|
||
{
|
||
}
|
||
|
||
// 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,3}';
|
||
|
||
#[Route('/', name: 'home', methods: ['GET'])]
|
||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||
public function page(Request $request, string $path = ''): Response
|
||
{
|
||
$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) {
|
||
$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);
|
||
}
|
||
|
||
public function notFound(Request $request, int $status = 404): Response
|
||
{
|
||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||
}
|
||
|
||
/** 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 = '';
|
||
$googleFonts = null;
|
||
$localFonts = [];
|
||
// The page decides which header and footer it gets - including none.
|
||
$areas = $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 = $this->sections($page['document'], $areas);
|
||
$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));
|
||
// 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.
|
||
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
|
||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $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);
|
||
}
|
||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||
// die Datei noch die IP-Adresse des Besuchers.
|
||
if ($this->fonts->hasLocal($theme)) {
|
||
$localFonts = $this->fonts->preload($theme);
|
||
} else {
|
||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||
}
|
||
$response = $this->render('page.html.twig', [
|
||
'page' => $page,
|
||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||
'navigation' => $this->navigation($path),
|
||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||
// 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']) : 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']) : [],
|
||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||
// 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' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||
// 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',
|
||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||
'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' => $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' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||
'pageStyles' => $styles,
|
||
'googleFonts' => $googleFonts,
|
||
'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() && $this->hasType($page['document'], 'form');
|
||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||
if ($mitTurnstile) {
|
||
$frames[] = 'challenges.cloudflare.com';
|
||
}
|
||
|
||
if ($status >= 400) {
|
||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||
}
|
||
|
||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||
}
|
||
|
||
/**
|
||
* 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 embeds that are actually rendered on this response.
|
||
* Nothing is added to the policy for an embed still waiting
|
||
* for consent.
|
||
*/
|
||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
|
||
{
|
||
$styleSources = ["'self'"];
|
||
if ('' !== $styles) {
|
||
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
|
||
}
|
||
$fontSources = ["'self'"];
|
||
if ($googleFonts) {
|
||
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
|
||
$styleSources[] = 'https://fonts.googleapis.com';
|
||
$fontSources[] = 'https://fonts.gstatic.com';
|
||
}
|
||
$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),
|
||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||
));
|
||
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* Prepares one state per form block: the definition, whether this request just sent it, and a
|
||
* signed timestamp the submission route uses to tell a person from a script.
|
||
*
|
||
* @return array<string, array>
|
||
*/
|
||
/**
|
||
* Der Zustand jedes Formulars auf dieser Seite.
|
||
*
|
||
* 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, 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'] ?? '';
|
||
$states[$block['id']] = [
|
||
'form' => $this->forms->findBySlug($slug),
|
||
'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'] ?? []);
|
||
}
|
||
};
|
||
$walk($page['document']['blocks'] ?? []);
|
||
foreach ($uebergeben as $blockId => $zustand) {
|
||
if (isset($states[$blockId])) {
|
||
$states[$blockId] = [...$states[$blockId], ...$zustand];
|
||
}
|
||
}
|
||
|
||
return $states;
|
||
}
|
||
|
||
/**
|
||
* 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}>
|
||
*/
|
||
private function breadcrumbs(array $page): array
|
||
{
|
||
if ($page['path'] === $this->settings->homePath()) {
|
||
return [];
|
||
}
|
||
$trail = [];
|
||
$prefix = '';
|
||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
|
||
$ancestor = $this->pages->findPublished($prefix);
|
||
if (null !== $ancestor) {
|
||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$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 '';
|
||
}
|
||
$farbe = static function (string $wert) use ($theme): ?string {
|
||
if ('' === $wert) {
|
||
return null;
|
||
}
|
||
if (!str_starts_with($wert, '@')) {
|
||
return $wert;
|
||
}
|
||
foreach ($theme['colors'] as $eintrag) {
|
||
if ($eintrag['key'] === substr($wert, 1)) {
|
||
return sprintf('var(--k-%s)', $eintrag['key']);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
$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 = $farbe((string) ($settings[$feld] ?? ''));
|
||
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.'/'.$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;
|
||
}
|
||
$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
|
||
{
|
||
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
|
||
$term = null;
|
||
if (null !== $slug) {
|
||
$term = $this->posts->findTerm($kind, $slug);
|
||
if (null === $term) {
|
||
return $this->notFound($request);
|
||
}
|
||
}
|
||
$page = max(1, $request->query->getInt('seite', 1));
|
||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||
// 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'];
|
||
|
||
$areas = $this->areas();
|
||
$frames = $this->frameHosts($request, ...array_values($areas));
|
||
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
|
||
|
||
return $this->shell($this->render('archive.html.twig', [
|
||
'areas' => $areas,
|
||
'needsScrolled' => $needsScrolled,
|
||
'navigation' => $this->navigation(''),
|
||
'term' => $term,
|
||
'heading' => null === $term ? 'Beiträge' : $term['name'],
|
||
'result' => $result,
|
||
'base' => $base,
|
||
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
|
||
'navigation' => $this->navigation(''),
|
||
]), $needsScrolled, frameHosts: $frames);
|
||
}
|
||
|
||
#[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 $document, 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']);
|
||
$lists[$block['id']] = $paginate ? $result : $result['posts'];
|
||
}
|
||
$walk($block['children'] ?? []);
|
||
}
|
||
};
|
||
$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 [];
|
||
}
|
||
|
||
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 embeds that may load on this request.
|
||
*
|
||
* An embed whose group is not granted contributes nothing: its host must not appear in the
|
||
* policy, because the frame is not rendered either.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function frameHosts(Request $request, array ...$documents): array
|
||
{
|
||
$hosts = [];
|
||
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
|
||
foreach ($blocks as $block) {
|
||
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
|
||
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
|
||
if (is_string($host)) {
|
||
$hosts[$host] = true;
|
||
}
|
||
}
|
||
$walk($block['children'] ?? []);
|
||
}
|
||
};
|
||
foreach ($documents as $document) {
|
||
$walk($document['blocks'] ?? []);
|
||
}
|
||
|
||
return array_keys($hosts);
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
*/
|
||
private function areas(?array $page = null): array
|
||
{
|
||
$areas = [];
|
||
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'];
|
||
}
|
||
}
|
||
|
||
return $areas;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
return $links;
|
||
}
|
||
}
|