übersetzter Pfad (ADR 111). Null für die Hauptsprache. * * @var array{locale: string, map: array}|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('/]*>/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 */ private function alternates(array $page, int $status): array { $extra = $this->languages->extra(); if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) { return []; } $origin = rtrim($this->siteOrigin, '/'); $home = $this->settings->homePath(); $current = $this->translation['locale'] ?? $this->languages->main(); $main = $this->languages->main(); $list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]]; $translated = $this->pages->translatedPaths($page['id']); foreach ($extra as $locale) { if (isset($translated[$locale])) { $list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]]; } } if (count($list) < 2) { return []; } return array_map(fn (array $entry): array => [ ...$entry, 'native' => $this->languages->describe($entry['locale'])['native'], 'url' => $origin.$entry['href'], 'current' => $entry['locale'] === $current, ], $list); } /** * Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die * Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript. */ private function pluginRoute(Request $request, array $route): Response { $query = []; foreach ($request->query->all() as $key => $value) { if (is_string($value) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,40}$/D', (string) $key)) { $query[$key] = mb_substr($value, 0, 500); } } $response = $this->render($route['template'], ['settings' => $this->plugins->settingsOf($route['plugin']), 'query' => $query, 'route' => $route]); $response->headers->set('Content-Type', $route['contentType']); if ('html' !== $route['type']) { $response->headers->set('X-Robots-Tag', 'noindex'); } return $this->shell($response); } public function notFound(Request $request, int $status = 404): Response { $page = $this->notFoundPage->page($status); $locale = $this->translation['locale'] ?? null; // Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die // mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111). if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) { $page['document'] = Translations::apply($page['document'], $locale); } else { $page['document'] = Translations::strip($page['document']); if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) { $page['document'] = $this->translateFixedTexts($page['document'], $locale); } } if (null !== $locale) { $page['locale'] = $locale; } return $this->renderPage($request, $page, '', $status); } /** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */ private function translateFixedTexts(array $document, string $locale): array { $t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text; foreach (['title', 'description'] as $field) { $document[$field] = $t((string) ($document[$field] ?? '')); } $walk = function (array $blocks) use (&$walk, $t): array { foreach ($blocks as $index => $block) { if (is_string($block['props']['text'] ?? null)) { $blocks[$index]['props']['text'] = $t($block['props']['text']); } $blocks[$index]['children'] = $walk($block['children'] ?? []); } return $blocks; }; $document['blocks'] = $walk($document['blocks'] ?? []); return $document; } /** Published pages and error content share theme, areas, block rendering and CSP. */ private function renderPage(Request $request, array $page, string $path, int $status = 200): Response { $styles = ''; // The page decides which header and footer it gets - including none. $areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page)); $theme = $this->theme->load(); // Theme first, block styles after, so a block always wins over the site defaults. $sections = array_map($this->localise(...), $this->sections($page['document'], $areas)); // Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension). $request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main()); // Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen // Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß. $pristine = $sections; $seen = []; foreach (['header', 'footer'] as $kind) { if ('header' === $kind && isset($areas['header'])) { $areas['header'] = $this->partials->expandRepeatedLinks($areas['header'], $sections, $seen, $pristine); } if ('header' === $kind) { $page['document'] = $this->partials->expandRepeatedLinks($page['document'], $sections, $seen, $pristine); } if ('footer' === $kind && isset($areas['footer'])) { $areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine); } } $documents = [$page['document'], ...array_values($areas), ...array_values($sections)]; $needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider')); $needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc)); // Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften // Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten. $needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs')); // Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt. $pluginAssets = $this->plugins->assetsFor($documents); $needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc)); // Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext. // Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den // jemand in einen formatierten Absatz geschrieben hat. $needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: '')); // Only the header changes with the scroll position; a scrolled value anywhere in it asks // for the small script that marks the page as scrolled. // Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar. $needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false); $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); foreach ([...array_values($areas), ...array_values($sections)] as $extra) { $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); } // Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern. $forms = $this->formStates($page, $documents, $request->query->getString('gesendet'), $request); // Schritte, Bedingungen und Zusammenfassung brauchen das eine Formularskript - nur dort, // wo ein solches Formular steht. $needsFormScript = (bool) array_filter($forms, fn (array $state): bool => null !== $state['form'] && $this->forms->needsScript($state['form'])); $styled = []; foreach ($forms as $state) { if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) { $styled[$state['form']['slug']] = true; $styles .= $this->forms->css($state['form'], $theme); } } // Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die // Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com: // das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den // Seitenaufbau an zwei fremde Verbindungen nacheinander. $localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []; $areaScripts = []; foreach ($areas as $kind => $area) { if (isset($this->areaIds[$kind]) && [] !== $this->code->scriptsOf($area)) { $areaScripts[] = '/assets/bereich/'.$this->areaIds[$kind].'.js'; } } $postLists = $this->postLists($documents, max(1, $request->query->getInt('seite', 1))); // Eine blätternde Liste macht die Seite zu einer Folge von Seiten: die Canonical nennt die // Seitenzahl, prev und next verbinden die Folge - sonst hielte eine Suchmaschine Seite 3 // für ein Duplikat von Seite 1. $blaettern = null; foreach ($postLists as $liste) { if (isset($liste['pages']) && $liste['pages'] > 1) { $blaettern = $liste; break; } } $adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath())); $alternates = $this->alternates($page, $status); // Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die // Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen. $request->attributes->set('kant_alternates', $alternates); $canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : ''); // Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt // consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das // Formular mit Weiterleitung. $embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections)); $needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request); $response = $this->render('page.html.twig', [ 'needsConsent' => $needsConsent, 'page' => $page, 'canonical' => $canonical, 'alternates' => $alternates, 'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse], 'navigation' => $this->navigation($path), 'postLists' => $postLists, // Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte. 'hasPosts' => $this->posts->anyPublished(), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null, // Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den // Baustein wirklich enthält. 'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [], 'needsTabs' => $needsTabs, 'needsFormScript' => $needsFormScript, 'pluginStyles' => $pluginAssets['styles'], 'pluginScripts' => $pluginAssets['scripts'], // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der // dafür ein Skript braucht - nach demselben Muster wie die Tabs. 'needsSlider' => $needsSlider, 'needsMotion' => $needsMotion, 'needsScrolled' => $needsScrolled, // Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die // grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat. 'needsZoom' => $needsZoom, // Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein // gewoehnlicher Link. 'needsDocument' => $needsDocument, // Own scripts arrive as one file per page, never inline, so the policy can stay // hash based and no page without a script loses its strict setting. 'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js', 'areaScripts' => $areaScripts, 'forms' => $forms, 'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page), 'socialImage' => $this->socialImage($page), 'structuredData' => $status >= 400 ? [] : $this->structuredData($page), 'areas' => $areas, 'sections' => $sections, // Rendered before the page content, so the header decides first. // Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur // im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche // Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort. 'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]), // Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz // ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts. 'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']), // Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der // Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die // LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen. 'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null, 'pageStyles' => $styles, 'localFonts' => $localFonts, 'styleBundle' => $this->bundle->url([] !== $localFonts), ], new Response(status: $status)); // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. $mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form')); $wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document'])); $frames = $embeds['hosts']; if ($mitTurnstile) { $frames[] = 'challenges.cloudflare.com'; } if ($status >= 400) { $response->headers->set('X-Robots-Tag', 'noindex, follow'); } if (null !== $this->translation) { $response->setContent($this->localiseLinks((string) $response->getContent())); } return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument); } /** * Every public response carries the same headers. Scripting stays forbidden unless the page * actually contains a tab group: only then may the browser load the one enhancement script, * and only from this origin. Inline script is never allowed. * * The generated stylesheet is inline for speed, so it is allowed by its own hash rather than * by 'unsafe-inline' – any other inline style, injected or not, stays blocked. * * @param list $frameHosts Hosts of the embeds on this page, also of those still * waiting for consent (ADR 115: consent.js inserts them * without a reload, and a policy cannot change afterwards) * @param bool $frameSelf whether the page may frame its own PDFs (the viewer) */ private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response { $styleSources = ["'self'"]; if ('' !== $styles) { $styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true))); } // Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein // fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage. $fontSources = ["'self'"]; $response->headers->set('Cache-Control', 'no-store'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); // Diese Website braucht weder Kamera noch Mikrofon, Standort oder Bewegungssensoren. Das // hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer // eingebetteten fremden Seite - die Möglichkeit, danach zu fragen. $response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()'); // Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn // oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden. $scriptSources = $allowScript ? ["'self'"] : ["'none'"]; if (in_array('challenges.cloudflare.com', $frameHosts, true)) { $scriptSources = ["'self'", Turnstile::HOST]; } $response->headers->set('Content-Security-Policy', sprintf( "default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", implode(' ', $scriptSources), implode(' ', $styleSources), implode(' ', $fontSources), // Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo // eine Seite auf ein PDF verlinkt, und nur von hier. [] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]), )); return $response; } /** * Der Zustand jedes Formulars auf dieser Seite: die Definition, ob diese Anfrage es gerade * abgeschickt hat, und ein signierter Zeitstempel, an dem der Versand einen Menschen von * einem Skript unterscheidet. * * Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die * Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb * laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der * Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne * Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen. */ private function formStates(array $page, array $documents, string $sentSlug, ?Request $request = null): array { $issued = time(); $states = []; $uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : []; $walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void { foreach ($blocks as $block) { if ('form' === ($block['type'] ?? null)) { $slug = $block['props']['form'] ?? ''; $form = $this->forms->findBySlug($slug); // In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111). if (null !== $form && null !== $this->translation) { $form = FormRepository::localise($form, $this->translation['locale']); } $states[$block['id']] = [ 'form' => $form, // Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt. 'optionImages' => null === $form ? [] : $this->optionImages($form), 'sent' => '' !== $sentSlug && $sentSlug === $slug, 'errors' => [], 'values' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret), // Leer, solange die Pruefung aus ist - dann steht auch kein Widget da. 'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '', ]; } $walk($block['children'] ?? []); } }; // Formulare stehen auch im Kopf, im Fuss oder in einer verknuepften Sektion - ein // Kontaktformular im Fuss jeder Seite ist der haeufigste Fall. foreach ($documents as $document) { $walk($document['blocks'] ?? []); } // Die Meldungen aus dem Formularversand gelten jedem Block, der dieses Formular zeigt. if (isset($uebergeben['slug'])) { foreach ($states as $blockId => $state) { if (($state['form']['slug'] ?? null) === $uebergeben['slug']) { $states[$blockId] = [...$state, 'errors' => $uebergeben['errors'], 'values' => $uebergeben['values'], 'sent' => false]; } } } return $states; } /** * Die Bilder der Optionen eines Formulars: Kennung => kleinste Fassung mit Massen. Ein Bild, * das die Mediathek nicht mehr kennt, fehlt hier - die Option zeigt dann ihren Text. * * @return array */ 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 */ /** @return list> */ 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('#|

#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 */ /** * Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet. * * Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht * zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der * Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash. */ private function scrollTopStyles(array $settings, array $theme): string { if (!($settings['scrollTop'] ?? false)) { return ''; } $werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))]; $werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') { 'eckig' => '0', 'weich' => '12px', default => '999px', }); $werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400)); foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) { $wert = Theme::cssColor((string) ($settings[$feld] ?? ''), $theme); if (null !== $wert) { $werte[] = $variable.':'.$wert; } } return ':root{'.implode(';', $werte).'}'; } /** * Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz. * * Zuerst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild. Dieses * Feld gab es schon, ausgegeben wurde es nie: eine Einstellung, die speichert und nichts tut. * Die Adresse muss absolut sein, weil sie in fremden Diensten landet, und die Variante nicht * die größte - 1600 px reichen jedem Vorschaubild, 2400 kostet nur Ladezeit beim Teilen. * * @return array{url: string, alt: string}|null */ private function socialImage(array $page): ?array { $id = (string) ($page['og_media_id'] ?? '') ?: (string) ($page['featured_media_id'] ?? ''); if ('' === $id) { return null; } $item = $this->media->find($id); if (null === $item || [] === $item['variants']) { return null; } $passend = array_filter($item['variants'], static fn (array $variant): bool => $variant['width'] <= 1600); $variant = end($passend) ?: $item['variants'][0]; return ['url' => rtrim($this->siteOrigin, '/').$variant['src'], 'alt' => (string) ($item['alt'] ?? '')]; } private function structuredData(array $page): array { $origin = rtrim($this->siteOrigin, '/'); $blocks = []; if ($page['path'] === $this->settings->homePath()) { $organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']); if (null !== $organisation) { $blocks[] = $organisation; } } if ('post' === $page['type']) { $article = [ '@context' => 'https://schema.org', '@type' => 'Article', 'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110), 'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']), ]; foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) { if ('' !== (string) $value && null !== $value) { $article[$key] = 'datePublished' === $key ? str_replace(' ', 'T', (string) $value).'Z' : (string) $value; } } if (null !== ($page['author'] ?? null) && '' !== $page['author']) { $article['author'] = ['@type' => 'Person', 'name' => $page['author']]; } $bild = $this->socialImage($page); if (null !== $bild) { $article['image'] = $bild['url']; } $blocks[] = $article; } // Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die // Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier. $fragen = $this->faqEntries($page['document']['blocks'] ?? []); if ([] !== $fragen) { $blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen]; } $trail = $this->breadcrumbs($page); if (count($trail) > 1) { $blocks[] = [ '@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => array_map( static fn (int $index, array $item): array => ['@type' => 'ListItem', 'position' => $index + 1, 'name' => $item['name'], 'item' => $origin.$item['href']], array_keys($trail), $trail, ), ]; } return $blocks; } /** True when the stored document contains at least one block of that type. */ /** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */ private function hasZoom(array $document): bool { $gefunden = false; $walk = function (array $blocks) use (&$walk, &$gefunden): void { foreach ($blocks as $block) { if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) { $gefunden = true; return; } $walk($block['children'] ?? []); } }; $walk($document['blocks'] ?? []); return $gefunden; } /** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */ private function hasScrolled(array $document): bool { $walk = function (array $blocks) use (&$walk): bool { foreach ($blocks as $block) { foreach ($block['style'] ?? [] as $values) { if ([] !== ($values['scrolled'] ?? [])) { return true; } } if ($walk($block['children'] ?? [])) { return true; } } return false; }; return $walk($document['blocks'] ?? []); } private function hasEntrance(array $document): bool { $walk = function (array $blocks) use (&$walk): bool { foreach ($blocks as $block) { if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) { return true; } } return false; }; return $walk($document['blocks'] ?? []); } private function hasType(array $document, string $type): bool { $found = false; $walk = function (array $blocks) use (&$walk, &$found, $type): void { foreach ($blocks as $block) { $found = $found || $type === ($block['type'] ?? null); $walk($block['children'] ?? []); } }; $walk($document['blocks'] ?? []); return $found; } #[Route('/beitraege', name: 'posts', methods: ['GET'])] #[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] #[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] public function archive(Request $request, ?string $slug = null): Response { $this->translation = null; return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug); } private function renderArchive(Request $request, string $kind, ?string $slug): Response { $language = $this->translation['locale'] ?? null; $t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text); $term = null; if (null !== $slug) { $term = $this->posts->findTerm($kind, $slug); if (null === $term) { return $this->notFound($request); } $term['name'] = $this->posts->termName($term, $language); } $page = max(1, $request->query->getInt('seite', 1)); $result = $this->posts->published($page, $term['id'] ?? null, language: $language); // Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse - // egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist. if (0 === $result['total']) { return $this->notFound($request); } $base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug']; $heading = null === $term ? $t('Beiträge') : $term['name']; // Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe // Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften, // Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine // eigene Vorlage, die von alledem nur die Palette kannte. $kopf = [ ['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]], ['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]], ]; if (null !== $term) { array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('Schlagwort')], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]); } $document = [ 'schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => $heading, 'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $term['name']), 'blocks' => [ ['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf], ['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [ ['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]], ]], ], ]; $seite = [ 'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(), 'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document, ]; return $this->renderPage($request, $seite, $seite['path']); } #[Route('/feed.xml', name: 'feed', methods: ['GET'])] public function feed(Request $request): Response { if (!$this->posts->anyPublished()) { return $this->notFound($request); } $result = $this->posts->published(1, null, 20); $response = $this->shell($this->render('feed.xml.twig', [ 'posts' => $result['posts'], 'origin' => rtrim($this->siteOrigin, '/'), ])); $response->headers->set('Content-Type', 'application/xml; charset=UTF-8'); return $response; } /** * Collects the posts each post list block needs. Stored content never carries the posts * themselves, so a saved document cannot claim a post it is not allowed to show. * * @return array> */ 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 * ``, 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 */ 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, 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 */ /** Die Kennungen der Bereiche dieser Antwort, fuer ihre Skriptdateien. @var array */ 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 */ 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> */ 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; } }