Files
KantSites/src/Controller/PageController.php
T
kantarciandClaude Opus 5 d6d476177a
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
Vier Wuensche und ein Fund beim Nachmessen.

- Farbwaehler im Designbereich. Das Textfeld bleibt massgeblich, damit
  "@akzent" ein Verweis auf die Palette bleibt und nicht beim ersten
  Klick zu einem festen Hexwert erstarrt (ADR 066).
- Die Verwaltung auf dem Telefon: das Menue klappt unter 760 px zu und
  nennt den Bereich, in dem man steht; Ziele 44 px, kein Skript.
- Eigene SVG-Symbole statt Unicode-Zeichen - ein Telefon ersetzt die
  sonst durch Emoji in fremdem Stil. Zwei Eintraege trugen dabei
  denselben Pfad; ein Test findet beides (ADR 067).
- Update aus dem Releasearchiv: der Weg fuer Installationen ohne Git.
  Zwei Schritte, weil ein Prozess, der sich selbst ausgetauscht hat,
  mit dem alten Code weiterarbeitet. var/, .env, hochgeladene
  Schriften und das erzeugte Buendel bleiben unberuehrt; public/ wird
  dazu Datei fuer Datei zusammengefuehrt. Geprueft ohne Netz gegen
  eine Releaseseite im Speicher (ADR 068).

Beim Nachmessen der echten Startseite statt einer Testseite: das
groesste Element ueber der Falz ist dort kein <img>, sondern der
Hintergrund des ersten Abschnitts - und der steht nur im Stylesheet.
Drei Vorabverweise mit denselben Geraetegrenzen wie der Compiler holen
genau die Variante, die die Regel auch nimmt. LCP 1,3 s -> 1,1 s.
Dabei gefunden: dasselbe Bild wurde je Geraeteregel neu abgefragt -
Startseite 19 -> 15 Abfragen (ADR 069).

Geprueft: 428 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und
Linkpruefung, Lighthouse auf drei echten Seiten (alles ausser der
Leistung der Startseite 100).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:01:32 +02:00

719 lines
33 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\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
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)
{
}
// 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);
$styles = '';
$googleFonts = null;
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
if (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);
$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);
}
}
if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does.
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
'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' => $this->hasAutoplaySlider($page['document']),
// 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' => $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $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),
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
]);
}
// 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 = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
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;
}
/** True when a slider on this page asked to advance by itself. */
private function hasAutoplaySlider(array $document): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found): void {
foreach ($blocks as $block) {
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
$found = true;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
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->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
}
$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->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$frames = $this->frameHosts($request, ...array_values($this->areas()));
return $this->shell($this->render('archive.html.twig', [
'areas' => $this->areas(),
'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(''),
]), frameHosts: $frames);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(): Response
{
if (!$this->posts->anyPublished()) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
$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;
}
}