Menüs ersetzen die fest verdrahtete Navigation (Arbeitspaket A)
Migration 20260912000500 legt eine Tabelle menu an: ein Menü je Platz (header, footer) und Sprache, Elementbaum als JSON, Version für dieselbe Konflikterkennung wie bei Seiten. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, mailto:, tel:). Seitenverweise speichern die Seiten-ID, nie den Pfad - ein Eintrag folgt damit einer umbenannten Seite. Beim Rendern fallen Einträge zu gelöschten oder unveröffentlichten Seiten heraus, ohne den gespeicherten Baum zu ändern und ohne die übrigen Einträge zu verlieren. Die Verwaltung nutzt eine nummerierte Zeilenliste statt Drag-and-drop und funktioniert damit vollständig ohne JavaScript; Untermenüs in der Ausgabe über details/summary. tests/bootstrap.php verwirft var/cache/test je Lauf. Ohne Debug prüft Symfony weder den kompilierten Container noch die Vorlagen auf Aktualität, was zweimal ein falsches Testergebnis erzeugt hat. Geprüft: 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22, 11 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst gegen eine Datenbankkopie, wiederholtes Seeding. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a60adf7d7
commit
de9d6e3da8
22 files changed
+765
-19
No files matched your search
@@ -3,6 +3,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -13,7 +14,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
#[AsCommand(name: 'cms:seed-demo', description: 'Create missing demo pages without overwriting existing content.')]
|
||||
final class SeedDemoCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private PageRepository $pages)
|
||||
public function __construct(private Connection $db, private PageRepository $pages, private MenuRepository $menus)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -35,8 +36,40 @@ final class SeedDemoCommand extends Command
|
||||
$this->pages->publish($id, $revision, 1);
|
||||
$output->writeln('Created: '.$slug);
|
||||
}
|
||||
$this->seedMenus($output);
|
||||
});
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Only fills an empty slot, so an edited menu is never overwritten by seeding again. */
|
||||
private function seedMenus(OutputInterface $output): void
|
||||
{
|
||||
$existing = $this->menus->all();
|
||||
$pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path FROM page WHERE website_id = ? AND locale = ?', ['default', 'de']) as $page) {
|
||||
$pages[$page['path']] = $page['id'];
|
||||
}
|
||||
$defaults = [
|
||||
'header' => [['Überblick', 'home'], ['Grundlagen', 'grundlagen'], ['Projektstand', 'projektstand']],
|
||||
'footer' => [['Überblick', 'home']],
|
||||
];
|
||||
foreach ($defaults as $slot => $entries) {
|
||||
if (isset($existing[$slot])) {
|
||||
$output->writeln('Menu unchanged: '.$slot);
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach ($entries as [$label, $path]) {
|
||||
if (isset($pages[$path])) {
|
||||
$items[] = ['label' => $label, 'type' => 'page', 'pageId' => $pages[$path], 'anchor' => '', 'children' => []];
|
||||
}
|
||||
}
|
||||
if ([] === $items) {
|
||||
continue;
|
||||
}
|
||||
$this->menus->save($slot, MenuRepository::SLOTS[$slot], $items, 0);
|
||||
$output->writeln('Menu created: '.$slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Menus are small enough to live as one validated item tree per slot. An item points at a page
|
||||
* (optionally at an anchor inside it) or at an external address. Page references are resolved when
|
||||
* the menu is rendered, never stored as a path: renaming or deleting a page must not silently
|
||||
* produce a dead link.
|
||||
*/
|
||||
final class MenuRepository
|
||||
{
|
||||
public const SLOTS = ['header' => 'Hauptnavigation', 'footer' => 'Fußzeile'];
|
||||
public const MAX_ITEMS = 60;
|
||||
public const MAX_DEPTH = 2;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{id: string, name: string, items: array, version: int}>
|
||||
*/
|
||||
public function all(string $locale = 'de'): array
|
||||
{
|
||||
$menus = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, slot, name, items, version FROM menu WHERE website_id = ? AND locale = ?', ['default', $locale]) as $row) {
|
||||
$menus[$row['slot']] = [
|
||||
'id' => $row['id'], 'name' => $row['name'], 'version' => (int) $row['version'],
|
||||
'items' => json_decode($row['items'], true, 512, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
}
|
||||
|
||||
return $menus;
|
||||
}
|
||||
|
||||
public function save(string $slot, string $name, array $items, int $expectedVersion, string $locale = 'de'): void
|
||||
{
|
||||
if (!isset(self::SLOTS[$slot])) {
|
||||
throw new InvalidMenu('Unbekannter Menüplatz.');
|
||||
}
|
||||
$name = trim($name);
|
||||
if ('' === $name || mb_strlen($name) > 120) {
|
||||
throw new InvalidMenu('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
|
||||
}
|
||||
$this->validate($items);
|
||||
$this->db->transactional(function () use ($slot, $name, $items, $expectedVersion, $locale): void {
|
||||
$encoded = json_encode($items, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$existing = $this->db->fetchAssociative('SELECT id, version FROM menu WHERE website_id = ? AND locale = ? AND slot = ?', ['default', $locale, $slot]);
|
||||
if (false === $existing) {
|
||||
if (0 !== $expectedVersion) {
|
||||
throw new StaleWrite('Das Menü wurde inzwischen angelegt. Lade die Seite neu.');
|
||||
}
|
||||
$this->db->insert('menu', ['id' => bin2hex(random_bytes(16)), 'website_id' => 'default', 'locale' => $locale, 'slot' => $slot, 'name' => $name, 'items' => $encoded, 'version' => 1]);
|
||||
|
||||
return;
|
||||
}
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE menu SET name = ?, items = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[$name, $encoded, $existing['id'], $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Das Menü wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the stored tree into links. Items whose page is gone or not published are dropped:
|
||||
* a deleted page must not break the navigation or expose a draft.
|
||||
*
|
||||
* @return list<array{label: string, href: string, current: bool, children: list<array>}>
|
||||
*/
|
||||
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
|
||||
{
|
||||
$pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ?', ['default', $locale]) as $page) {
|
||||
$pages[$page['id']] = $page;
|
||||
}
|
||||
|
||||
return $this->resolveLevel($items, $pages, $currentPath);
|
||||
}
|
||||
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath): array
|
||||
{
|
||||
$links = [];
|
||||
foreach ($items as $item) {
|
||||
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath);
|
||||
$label = $item['label'] ?? '';
|
||||
if ('page' === $item['type']) {
|
||||
$page = $pages[$item['pageId']] ?? null;
|
||||
if (null === $page || null === $page['published_revision_id']) {
|
||||
// Keep any resolvable children rather than dropping a whole branch.
|
||||
array_push($links, ...$children);
|
||||
continue;
|
||||
}
|
||||
$path = 'home' === $page['path'] ? '/' : '/'.$page['path'];
|
||||
$anchor = $item['anchor'] ?? '';
|
||||
$href = $path.('' === $anchor ? '' : '#'.$anchor);
|
||||
// An anchor link is never the current page on its own; it jumps inside one.
|
||||
$current = '' === $anchor && $page['path'] === $currentPath;
|
||||
} else {
|
||||
$href = $item['url'];
|
||||
$current = false;
|
||||
}
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children];
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
private function validate(array $items, int $depth = 1, int &$count = 0): void
|
||||
{
|
||||
if (!array_is_list($items)) {
|
||||
throw new InvalidMenu('Die Menüstruktur ist ungültig.');
|
||||
}
|
||||
if ([] !== $items && $depth > self::MAX_DEPTH) {
|
||||
throw new InvalidMenu(sprintf('Mehr als %d Menüebenen sind nicht vorgesehen.', self::MAX_DEPTH));
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if (++$count > self::MAX_ITEMS) {
|
||||
throw new InvalidMenu(sprintf('Ein Menü fasst höchstens %d Einträge.', self::MAX_ITEMS));
|
||||
}
|
||||
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
|
||||
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
}
|
||||
$anchor = $item['anchor'] ?? '';
|
||||
if (!is_string($anchor) || ('' !== $anchor && 1 !== preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,64}$/D', $anchor))) {
|
||||
throw new InvalidMenu('Ein Sprungziel besteht aus Buchstaben, Ziffern, Bindestrich und Unterstrich und beginnt mit einem Buchstaben.');
|
||||
}
|
||||
} elseif ('url' === ($item['type'] ?? null)) {
|
||||
$this->url($item['url'] ?? null);
|
||||
} else {
|
||||
throw new InvalidMenu('Unbekannte Zielart.');
|
||||
}
|
||||
$this->validate($item['children'] ?? [], $depth + 1, $count);
|
||||
}
|
||||
}
|
||||
|
||||
private function url(mixed $url): void
|
||||
{
|
||||
if (!is_string($url) || strlen($url) > 2048 || preg_match('/[\x00-\x20\\\\]/', $url)) {
|
||||
throw new InvalidMenu('Die Adresse ist leer, zu lang oder enthält unerlaubte Zeichen.');
|
||||
}
|
||||
// tel: and mailto: are real navigation targets on the pilot site, so they belong here.
|
||||
$allowed = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL);
|
||||
$allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//'));
|
||||
$allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url);
|
||||
$allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url);
|
||||
if (!$allowed) {
|
||||
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -138,7 +139,12 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
$this->validator->validate($document);
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true]);
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MenuController extends AbstractController
|
||||
{
|
||||
public function __construct(private MenuRepository $menus, private PageRepository $pages)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$menus = $this->menus->all();
|
||||
$rows = [];
|
||||
foreach (MenuRepository::SLOTS as $slot => $label) {
|
||||
$rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []);
|
||||
}
|
||||
$pages = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']];
|
||||
}
|
||||
|
||||
return $this->render('admin/menus.html.twig', [
|
||||
'slots' => MenuRepository::SLOTS,
|
||||
'menus' => $menus,
|
||||
'rows' => $rows,
|
||||
'pages' => $pages,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->menus->save(
|
||||
$slot,
|
||||
$request->request->getString('name'),
|
||||
$this->tree($request->request->all('items')),
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
} catch (InvalidMenu|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Menü wurde gespeichert.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the stored tree back into the flat rows the form edits.
|
||||
*
|
||||
* @return list<array{label: string, type: string, pageId: string, anchor: string, url: string, level: int}>
|
||||
*/
|
||||
private function flatten(array $items, int $level = 1): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($items as $item) {
|
||||
$rows[] = [
|
||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form posts a flat, numbered list so it works without JavaScript. Order comes from the
|
||||
* position field, nesting from the level field: a level-2 row joins the last level-1 row.
|
||||
* Rows without a label are treated as removed, which is what the empty "add" row is for.
|
||||
*/
|
||||
private function tree(array $rows): array
|
||||
{
|
||||
$flat = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$flat[] = [
|
||||
'label' => trim((string) $row['label']),
|
||||
'type' => (string) ($row['type'] ?? 'page'),
|
||||
'pageId' => (string) ($row['pageId'] ?? ''),
|
||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||
'url' => trim((string) ($row['url'] ?? '')),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
}
|
||||
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
|
||||
|
||||
$items = [];
|
||||
foreach ($flat as $row) {
|
||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
$item['pageId'] = $row['pageId'];
|
||||
$item['anchor'] = $row['anchor'];
|
||||
}
|
||||
if (1 === $row['level'] || [] === $items) {
|
||||
$items[] = $item;
|
||||
continue;
|
||||
}
|
||||
$items[array_key_last($items)]['children'][] = $item;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_menus');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -10,7 +11,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ final class PageController extends AbstractController
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path),
|
||||
'navigation' => $this->navigation($path),
|
||||
]);
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
@@ -42,4 +44,17 @@ final class PageController extends AbstractController
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
private function navigation(string $currentPath): array
|
||||
{
|
||||
$links = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
/** A menu the CMS refuses to store, with a message meant for the editor. */
|
||||
final class InvalidMenu extends \RuntimeException
|
||||
{
|
||||
}
|
||||
Reference in new issue
Block a user