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 $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 */ /** * 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 */ 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 */ /** * 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> */ 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 * ``, 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 []; } 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 */ 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 */ 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 */ 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); } return $links; } }