attributes->get('_route') ? 'post' : 'page'; $query = mb_substr(trim($request->query->getString('q')), 0, 160); $status = $request->query->getString('status', 'all'); $status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all'; $result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status); $pages = $result['items']; $parentChoices = []; foreach ($pages as &$page) { $page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); $page['title'] = $page['document']['title'] ?? $page['slug']; $page['depth'] = substr_count((string) $page['path'], '/'); $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => '']; if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) { $parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null; } } unset($page); $view = [ 'pages' => 'trash' === $status ? [] : $pages, 'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [], 'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(), 'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status, 'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages', 'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')], // Vorhandene Kategorien und Schlagwoerter als Vorschlag - sonst entstehen „Projekt“ // und „Projekte“ nebeneinander. 'termNames' => 'post' === $type ? $this->termNames() : ['category' => [], 'tag' => []], ]; if ($request->isXmlHttpRequest()) { return $this->json([ 'html' => $this->renderView('admin/content-results.html.twig', $view), 'total' => $result['total'], 'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']], ]); } return $this->render('admin/pages.html.twig', $view); } #[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])] public function contentChoices(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_EDITOR'); return $this->json(['items' => $this->pages->choices( mb_substr($request->query->getString('q'), 0, 160), $request->query->getString('type'), $request->query->getBoolean('parents'), )]); } #[Route('/pages', name: 'admin_create', methods: ['POST'])] public function create(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } $title = trim($request->request->getString('title')); if ('' === $title || mb_strlen($title) > 160) { return $this->back('Bitte gib einen Titel mit höchstens 160 Zeichen an.'); } $type = $request->request->getString('type', 'page'); $parentId = $request->request->getString('parent_id') ?: null; $slug = trim($request->request->getString('slug')) ?: $this->slugify($title); try { // Ein Beitrag beginnt mit einem Aufbau, eine Seite leer: bei einer Seite weiß // niemand vorher, was daraus wird, bei einem Beitrag fast immer. $layout = $request->request->getString('layout', PostLayouts::DEFAULT); $document = 'post' === $type && PostLayouts::exists($layout) ? PostLayouts::document($layout, $title) : DemoContent::starter($title, $type); $id = $this->pages->createPage($slug, $parentId, $type, $document); } catch (InvalidPage|InvalidDocument $e) { return $this->back($e->getMessage()); } $this->addFlash('success', sprintf('„%s“ wurde als Entwurf angelegt. Die Seite ist erst nach dem Veröffentlichen öffentlich.', $title)); return $this->redirectToRoute('admin_editor', ['id' => $id]); } /** @return array{category: list, tag: list} */ private function termNames(): array { $names = ['category' => [], 'tag' => []]; foreach ($this->posts->allTerms() as $term) { if (isset($names[$term['kind']])) { $names[$term['kind']][] = $term['name']; } } return $names; } /** The editor reads the style vocabulary and the palette from here, so both stay in one place. */ #[Route('/api/design', name: 'admin_design', methods: ['GET'])] public function design(): JsonResponse { $theme = $this->theme->load(); return $this->json([ 'properties' => StyleSchema::properties(), 'devices' => StyleSchema::DEVICES, 'sides' => StyleSchema::sides(), 'corners' => StyleSchema::corners(), 'hoverable' => StyleSchema::hoverable(), 'scrollable' => StyleSchema::scrollable(), 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'presets' => $theme['presets'], 'base' => $theme['base'], 'buttons' => $theme['buttons'], 'buttonOptions' => \App\Content\ButtonDesign::OPTIONS, 'buttonPresets' => \App\Content\ButtonDesign::PRESETS, 'csrfToken' => $this->csrf->getToken('editor')->getValue(), // Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll // die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen. 'choices' => $this->choices(), ]); } /** @return array> Adresse => Name, je Art */ private function choices(): array { $choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []]; foreach ($this->forms->all() as $form) { $choices['forms'][$form['slug']] = $form['name']; } foreach ($this->posts->allTerms() as $term) { $choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name']; } return $choices; } /** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */ #[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])] public function stylePreset(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) { return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403); } try { $body = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR); $style = $body['style'] ?? null; $this->theme->savePreset((string) ($body['name'] ?? ''), is_array($style) ? $style : null, $this->styles); } catch (InvalidPage|InvalidDocument $e) { return $this->json(['error' => $e->getMessage()], 422); } catch (\JsonException) { return $this->json(['error' => 'Die Anfrage ist unvollständig.'], 422); } return $this->json(['presets' => $this->theme->load()['presets']]); } #[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])] public function metadata(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } if ($blocked = $this->liveNeedsPublisher($id, false)) { return $blocked; } $terms = static function (string $raw): array { $names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n); return array_values(array_unique($names)); }; try { $this->posts->saveMetadata( $id, [ 'excerpt' => $request->request->getString('excerpt'), 'author' => $request->request->getString('author'), 'publishedAt' => $request->request->getString('publishedAt'), 'featuredMediaId' => $request->request->getString('featuredMediaId'), 'ogMediaId' => $request->request->getString('ogMediaId'), 'seoTitle' => $request->request->getString('seoTitle'), 'robots' => $request->request->getString('robots', 'index'), ], $terms($request->request->getString('categories')), $terms($request->request->getString('tags')), $request->request->getInt('expectedVersion', -1), ); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back('Die Beitragsdaten wurden gespeichert.', 'success'); } /** Which header and footer this page uses, including none at all. */ #[Route('/pages/{id}/bereiche', name: 'admin_page_areas', methods: ['POST'])] public function areas(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } if ($blocked = $this->liveNeedsPublisher($id, false)) { return $blocked; } // An empty field means "the standard area"; that is stored as null, not as an address. $choice = static fn (string $value): ?string => '' === $value ? null : $value; try { $this->pages->saveAreas( $id, $choice($request->request->getString('headerArea')), $choice($request->request->getString('footerArea')), $request->request->getInt('expectedVersion', -1), ); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back('Kopf- und Fußbereich dieser Seite wurden gespeichert.', 'success'); } #[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])] public function move(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } if ($blocked = $this->liveNeedsPublisher($id, true)) { return $blocked; } try { $moved = $this->pages->movePage( $id, $request->request->getString('slug'), $request->request->getString('parent_id') ?: null, $request->request->getInt('expectedVersion', -1), $this->redirects, ); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back(0 === $moved ? 'Die Adresse ist unverändert.' : sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success'); } /** * Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf * nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich, * Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher. */ private function liveNeedsPublisher(string $id, bool $subtree): ?Response { if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) { return null; } return $this->back($subtree ? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.' : 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.'); } #[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])] public function schedule(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_PUBLISHER'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } try { $this->pages->schedule($id, $request->request->getString('publishAt'), $request->request->getInt('expectedVersion', -1)); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back('' === trim($request->request->getString('publishAt')) ? 'Die geplante Veröffentlichung wurde entfernt.' : 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success'); } #[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])] public function restorePage(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } try { $this->pages->restorePage($id); } catch (InvalidPage $e) { return $this->back($e->getMessage()); } return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success'); } #[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])] public function restoreRevision(Request $request, string $id, string $revision): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } try { // Dieselbe Regel wie beim Speichern: Skripte aendert nur ein Administrator - auch nicht // ueber den Umweg einer alten Fassung. if (!$this->isGranted('ROLE_ADMIN')) { $this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']); $this->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']); } $this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1)); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back('Die Fassung ist als Entwurf zurück. Der bisherige Stand bleibt als eigene Fassung erhalten.', 'success'); } #[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])] public function revisions(Request $request, string $id): Response { $page = $this->page($id); return $this->render('admin/revisions.html.twig', [ 'page' => $page, 'title' => $page['document']['title'] ?? $page['path'], 'revisions' => $this->pages->revisions($id, $request->query->getInt('seite', 1)), ]); } #[Route('/pages/{id}/trash', name: 'admin_trash', methods: ['POST'])] public function trash(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } if ($blocked = $this->liveNeedsPublisher($id, true)) { return $blocked; } try { $count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1)); } catch (InvalidPage|StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back(sprintf('%d Seite(n) in den Papierkorb gelegt. Öffentlich nicht mehr erreichbar, aber wiederherstellbar.', $count), 'success'); } #[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])] public function delete(Request $request, string $id): Response { // The final removal deletes every revision, so it stays with the administrator role. // Everyone with the editor role uses the bin instead. $this->denyAccessUnlessGranted('ROLE_ADMIN'); if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } try { $this->pages->deletePage($id, $request->request->getInt('expectedVersion', -1)); } catch (InvalidPage $e) { return $this->back($e->getMessage()); } catch (StaleWrite $e) { return $this->back($e->getMessage()); } return $this->back('Die Seite wurde mit allen Ständen gelöscht.', 'success'); } /** Derives an address from the title so the editor does not have to invent one. */ private function slugify(string $title): string { $slug = strtr(mb_strtolower($title, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; return substr(trim($slug, '-'), 0, 80); } private function back(string $message, string $type = 'error'): Response { $this->addFlash($type, $message); $request = $this->requests->getCurrentRequest(); $post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page')); if (!$request?->request->has('_list_type')) { return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages'); } $params = [ 'q' => $request?->request->getString('_list_query', ''), 'status' => $request?->request->getString('_list_status', 'all'), 'seite' => $request?->request->getInt('_list_page', 1), 'limit' => $request?->request->getInt('_list_limit', 20), ]; foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) { if ($params[$key] === $default) { unset($params[$key]); } } return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params); } #[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])] public function editor(string $id): Response { $page = $this->page($id); return $this->render('admin/editor.html.twig', ['page' => $page]); } #[Route('/api/pages/{id}', name: 'admin_document', methods: ['GET'])] public function document(string $id): JsonResponse { return $this->json($this->payload($this->page($id))); } #[Route('/api/pages/{id}/draft', name: 'admin_save', methods: ['POST'])] #[Route('/api/pages/{id}/publish', name: 'admin_publish', methods: ['POST'])] #[Route('/api/pages/{id}/preview', name: 'admin_preview', methods: ['POST'])] public function write(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) { return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403); } // Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111). if (strlen($request->getContent()) > 1100000) { return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413); } $page = $this->page($id); try { $body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR); if (!is_array($body)) { throw new \InvalidArgumentException(); } $route = $request->attributes->get('_route'); if ('admin_preview' === $route) { $document = $body['document'] ?? null; if (!is_array($document)) { throw new \InvalidArgumentException(); } $this->validator->validate($document, writing: true); // Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten - // genau so, wie die Website sie in dieser Sprache ausliefert. $language = $body['language'] ?? null; $locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main(); $seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? ''); $document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale); $request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all())); $request->attributes->set('kant_locale', $locale); $localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale); $theme = $this->theme->load(); $areas = []; foreach (['header', 'footer'] as $kind) { $area = $this->partials->findByKind($kind); if (null !== $area) { $areas[$kind] = $localise($area['document']); } } $sections = []; foreach ($this->partials->referencedSlugs($document) as $slug) { $section = $this->partials->resolveSection($slug); if (null !== $section) { $sections[$slug] = $localise($section['document']); } } // Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen. $seen = []; $pristine = $sections; foreach (['header', 'footer'] as $kind) { if (isset($areas[$kind])) { $areas[$kind] = $this->partials->expandRepeatedLinks($areas[$kind], $sections, $seen, $pristine); } if ('header' === $kind) { $document = $this->partials->expandRepeatedLinks($document, $sections, $seen, $pristine); } } $navigation = []; foreach ($this->menus->all() as $slot => $menu) { $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale); } // Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem // CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher // fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr. $documents = [$document, ...array_values($areas), ...array_values($sections)]; $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']); foreach ($documents as $doc) { $styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc); } $forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale); foreach ($forms as $state) { if (null !== $state['form']) { $styles .= $this->forms->css($state['form'], $theme); } } return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) { throw new \InvalidArgumentException(); } if ('admin_publish' === $route) { $this->denyAccessUnlessGranted('ROLE_PUBLISHER'); // The user must publish the exact draft they reviewed, never whichever draft happens to be latest. if (($body['revisionId'] ?? null) !== $page['draft_revision_id']) { throw new StaleWrite(); } $this->pages->publish($id, $body['revisionId'], $body['expectedVersion']); $page['published_revision_id'] = $body['revisionId']; } else { if (!is_array($body['document'] ?? null)) { throw new \InvalidArgumentException(); } if (!$this->isGranted('ROLE_ADMIN')) { $this->code->assertScriptsUnchanged($body['document'], $page['document']); $this->code->assertHtmlUnchanged($body['document'], $page['document']); } $page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']); // Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern // bereinigt, und der Editor soll das sofort zeigen statt erst nach dem Neuladen. $page['document'] = $this->pages->findForEditor($id)['document'] ?? $body['document']; } $page['version'] = $body['expectedVersion'] + 1; return $this->json($this->payload($page)); } catch (StaleWrite) { return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409); } catch (InvalidDocument $e) { return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422); } catch (InvalidPage $e) { // Etwa eine übersetzte Adresse, die eine andere Seite schon hat. return $this->json(['error' => $e->getMessage()], 422); } catch (\JsonException|\InvalidArgumentException) { return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422); } } /** * Die Formulare der Vorschau: Definition und Optionsbilder, ohne Zeitstempel - in der Vorschau * wird nichts abgeschickt. * * @param list $documents * * @return array */ private function previewForms(array $documents, array $page, ?string $language = null): array { $states = []; $walk = function (array $blocks) use (&$walk, &$states, $page, $language): void { foreach ($blocks as $block) { if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) { $form = $this->forms->findBySlug((string) $block['props']['form']); if (null !== $form && null !== $language) { $form = FormRepository::localise($form, $language); } $states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => '']; } $walk($block['children'] ?? []); } }; foreach ($documents as $document) { $walk($document['blocks'] ?? []); } return $states; } private function page(string $id): array { return $this->pages->findForEditor($id) ?? throw $this->createNotFoundException('Seite nicht gefunden.'); } private function payload(array $page): array { return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(), // Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht. 'languages' => [ 'main' => $this->languages->describe($this->languages->main()), 'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), 'slug' => $page['slug'], 'home' => $page['path'] === $this->siteSettings->homePath(), 'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']), ], ]; } }