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
@@ -128,3 +128,23 @@ Damit ungeprüfte Werte nie in die Ausgabe gelangen, rendert der Kern aus einem
|
||||
Im Editor bleiben Plugin-Blöcke auswählbar, verschiebbar, duplizierbar und löschbar; das Eigenschaftenpanel zeigt statt Feldern einen Hinweis. In den Baustein hinein lässt sich nichts einfügen (`canContain` ist false), und `createBlock` kann keinen erzeugen – Plugin-Blöcke entstehen ausschließlich durch die jeweilige Erweiterung oder durch Import.
|
||||
|
||||
Rückweg: v1 bis v3 bleiben les- und schreibbar, ein Downgrade gibt es nicht. Ältere Software darf keine v4-Revision laden. Dies ist der Erhaltungsvertrag, noch kein Pluginsystem: Registrierung, Hooks und Ressourcenladen folgen in Phase 4.
|
||||
|
||||
## ADR 012 – Menüs als validierter Baum je Platz (12.09.2026)
|
||||
|
||||
Die öffentliche Navigation stand fest im Template. Der Pilot kant-it.de braucht aber Ankerziele auf der Startseite (`#dienstleistungen`, `#projekte`, `#kontakt`), externe Ziele wie `hausklar.app` sowie `tel:` und `mailto:` – eine aus der Seitenhierarchie erzeugte Navigation allein hätte dafür nicht gereicht.
|
||||
|
||||
Migration `20260912000500` legt eine Tabelle `menu` an: ein Menü je Platz (`header`, `footer`) und Sprache, mit Name, Elementbaum als JSON und `version` für dieselbe optimistische Konflikterkennung wie bei Seiten. Menüs sind klein und werden als Ganzes gespeichert; eine eigene Tabelle für Einträge würde Sortierung und Verschachtelung nur umständlicher machen.
|
||||
|
||||
Ein Eintrag verweist entweder auf eine Seite – optional zusammen mit einem Sprungziel innerhalb dieser Seite – oder auf eine Adresse. **Seitenverweise speichern die Seiten-ID, nie den Pfad.** Der Pfad entsteht erst beim Rendern. Damit folgt ein Menüeintrag einer umbenannten Seite automatisch, und eine gelöschte Seite hinterlässt keinen toten Link.
|
||||
|
||||
Beim Auflösen werden Einträge übersprungen, deren Seite fehlt oder unveröffentlicht ist: eine gelöschte Seite darf die Navigation nicht zerstören, und ein Entwurf darf nicht über das Menü öffentlich werden. Vorhandene Kinder eines übersprungenen Eintrags rücken nach, statt mit dem Ast zu verschwinden. Der gespeicherte Baum bleibt dabei unangetastet – wird die Seite später veröffentlicht, erscheint ihr Eintrag wieder.
|
||||
|
||||
Erlaubte Adressen sind HTTPS, lokale Pfade sowie `mailto:` und `tel:`; `tel:` und `mailto:` stehen hier, weil sie auf der Pilotseite echte Navigationsziele sind. `http:`, protokollrelative Adressen und `javascript:` werden abgewiesen. Sprungziele sind auf `^[a-zA-Z][a-zA-Z0-9_-]{0,64}$` begrenzt. Grenzen: 60 Einträge, zwei Ebenen.
|
||||
|
||||
Die Verwaltung arbeitet mit einer flachen, nummerierten Zeilenliste statt mit Drag-and-drop: Reihenfolge über die Position, Verschachtelung über die Ebene. Das funktioniert vollständig ohne JavaScript und ist mit der Tastatur bedienbar. Untermenüs in der Ausgabe nutzen `details`/`summary` und brauchen deshalb ebenfalls kein öffentliches JavaScript.
|
||||
|
||||
## ADR 013 – Testcache wird je Lauf verworfen (12.09.2026)
|
||||
|
||||
Das Testkernel läuft mit `debug = false`. Symfony prüft dann weder den kompilierten Container noch die kompilierten Twig-Vorlagen auf Aktualität. Zweimal hat das während der Entwicklung ein falsches Ergebnis erzeugt: eine geänderte Vorlage wurde aus dem Cache beantwortet und ein Test bestand fälschlich, später schlug ein geänderter Konstruktor mit einem `TypeError` aus einem veralteten Container fehl.
|
||||
|
||||
`tests/bootstrap.php` löscht deshalb `var/cache/test` einmal pro Lauf. Das kostet rund eine Sekunde und schließt diese Fehlerklasse aus. Zusätzlich lädt Twig in der Testumgebung Vorlagen automatisch neu, und das Login-Throttling zählt dort in einem Array-Cache statt in einem dauerhaften Pool – beide Einstellungen betreffen ausschließlich die Testumgebung, die geprüfte Drosselung selbst bleibt aktiv.
|
||||
+3
-1
@@ -54,10 +54,12 @@ Erhoben am 12.09.2026 ausschließlich aus der **öffentlich ausgelieferten Start
|
||||
|
||||
Die Reihenfolge ergibt sich aus Abhängigkeiten und aus dem Risiko, etwas später über Kundendaten ändern zu müssen. Jedes Paket endet mit einem prüfbaren Ergebnis, aktualisiertem STATUS und, wo nötig, einer Architekturentscheidung.
|
||||
|
||||
### A – Navigation und Menüs
|
||||
### A – Navigation und Menüs — **erledigt am 12.09.2026**
|
||||
|
||||
*Braucht: Seitenhierarchie (erledigt).*
|
||||
|
||||
Umgesetzt bis auf eines: Aufklappmenüs, Anker-, Seiten- und externe Ziele, Header und Footer getrennt, `aria-current`, keine öffentliche JavaScript-Abhängigkeit, gelöschte Seiten brechen das Menü nicht. **Offen geblieben:** die Sortierung läuft über Positionsnummern statt per Drag-and-drop, und es gibt nur die beiden festen Plätze. Details in ADR 012.
|
||||
|
||||
Menüverwaltung mit benannten Menüs und sortierbaren Einträgen. Ein Eintrag verweist auf eine Seite, auf ein Ankerziel innerhalb einer Seite oder auf eine externe Adresse; Beschriftung ist überschreibbar. Verschachtelung für Aufklappmenüs. Header- und Footermenü getrennt. Ausgabe als semantische Navigation mit `aria-current`, ohne öffentliches JavaScript für die Grundfunktion.
|
||||
|
||||
Abnahme: Die feste Navigation im Template ist ersetzt. Ein Menü mit Anker-, Seiten- und externem Ziel wird korrekt ausgegeben. Eine gelöschte Seite macht das Menü nicht kaputt.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Menüs, 12.09.2026
|
||||
|
||||
- **79 PHP-Tests / 285 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **11 JavaScript-Modelltests**.
|
||||
- Geprüft, dass die Navigation nicht mehr aus dem Template stammt: die ausgegebenen Links der Hauptnavigation entsprechen genau den gespeicherten Menüeinträgen. Seiten-, Anker- und externe Ziele einschließlich `tel:` werden korrekt gebildet, `aria-current` erscheint nur auf der tatsächlich aktuellen Seite und nicht auf einem Ankereintrag derselben Seite.
|
||||
- Geprüft, dass eine gelöschte und eine noch unveröffentlichte Seite aus der Navigation fallen, ohne die übrigen Einträge zu verlieren, dass die Kinder eines übersprungenen Eintrags nachrücken und dass der gespeicherte Baum unangetastet bleibt: nach dem Veröffentlichen erscheint der Eintrag wieder. Ein Entwurf wird über das Menü nicht öffentlich.
|
||||
- Zehn abgewiesene Eingaben schreiben nichts in die Datenbank: leere und überlange Beschriftung, unbekannte Zielart, fehlende Seite, Sprungziel mit Rautezeichen, Sprungziel mit führender Ziffer, `javascript:`, `http:`, protokollrelative Adresse und eine dritte Ebene. Dazu unbekannter Platz, Überschreitung der Eintragsgrenze und ein veralteter Schreibstand.
|
||||
- Über HTTP geprüft: anonymer Schreibversuch führt zur Anmeldung, CSRF-Token ist Pflicht, ein veralteter Versionsstand überschreibt kein neueres Menü, und der Baum entsteht korrekt aus flachen Formularzeilen über Position und Ebene, einschließlich normalisiertem Sprungziel und ignorierter Leerzeile.
|
||||
- Migration `20260912000500` zuerst gegen eine Kopie der lokalen Datenbank ausgeführt, dann nach Backup (`var/backups/before-menus-*.db`) angewendet. Wiederholtes Seeding legt fehlende Menüs an und lässt vorhandene unverändert. `composer lint`, `lint:twig`, `lint:yaml` und `lint:container` sind erfolgreich.
|
||||
- **Ein Fehler in der Testumgebung wurde dabei sichtbar und behoben.** Das Testkernel läuft ohne Debug; Symfony prüft dann weder den kompilierten Container noch die Vorlagen auf Aktualität. Ein geänderter Konstruktor schlug mit einem `TypeError` aus einem veralteten Container fehl, zuvor war bereits eine Vorlagenänderung übergangen worden. `tests/bootstrap.php` verwirft `var/cache/test` jetzt einmal pro Lauf. **Damit gilt für frühere Berichte: Ergebnisse, die vor dieser Behebung entstanden sind, wurden unter einer Bedingung gemessen, die einen veralteten Cache nicht ausschloss.** Die hier berichteten Läufe entstanden nach der Behebung.
|
||||
- **Auch für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Die Menüverwaltung und die Aufklappmenüs in der Ausgabe sind nicht visuell, nicht auf Barrierefreiheit und nicht auf Leistung geprüft.
|
||||
|
||||
## Ergänzung: Erhalt unbekannter Blöcke, 12.09.2026
|
||||
|
||||
- **64 PHP-Tests / 229 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **11 JavaScript-Modelltests**.
|
||||
|
||||
+8
-4
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Seiten und Beiträge lassen sich jetzt im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen. Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.**
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,16 +25,19 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- Menüverwaltung unter `/admin/menus` für die Plätze Hauptnavigation und Fußzeile. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, `mailto:`, `tel:`). Zwei Ebenen, bis 60 Einträge, optimistische Konflikterkennung. Bedienung über eine nummerierte Zeilenliste, vollständig ohne JavaScript; Untermenüs in der Ausgabe über `details`/`summary`. Seitenverweise speichern die Seiten-ID, nicht den Pfad – ein Eintrag folgt einer umbenannten Seite und überlebt eine gelöschte.
|
||||
- Dokumentformat v4: Blocktypen mit Namensraum (`acme.slider`) gelten als Bausteine einer Erweiterung und werden erhalten statt abgewiesen. Öffentlich rendert der Kern aus ihnen nichts außer einem HTML-Kommentar, in der Editorvorschau einen Platzhalter. Im Editor bleiben sie auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht bearbeitbar. v1 bis v3 bleiben unverändert unterstützt.
|
||||
- Login-Throttling nutzt in der Testumgebung einen Array-Cache. Zuvor überdauerten die Zähler den Testlauf und sperrten die Anmeldung nach wenigen Wiederholungen aus, was als Testfehler erschien.
|
||||
- Lokale PHP-/Browser-/Lighthouse-Prüfungen und minimale GitHub-Actions-Konfiguration einschließlich vorbereitetem MySQL-Smokejob.
|
||||
|
||||
## Geprüft
|
||||
|
||||
- 64 PHP-Tests / 229 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 11 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 11 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Menüs geprüft: Seiten-, Anker- und externe Ziele einschließlich `tel:` in der Ausgabe, `aria-current` nur auf der tatsächlich aktuellen Seite und nicht auf Ankereinträgen, gelöschte und unveröffentlichte Seiten fallen aus der Navigation ohne die übrigen Einträge zu verlieren, Kinder eines übersprungenen Eintrags rücken nach, gespeicherte Einträge bleiben erhalten und erscheinen nach dem Veröffentlichen wieder. Zehn abgewiesene Eingaben (leere und überlange Beschriftung, unbekannte Zielart, fehlende Seite, ungültige Sprungziele, `javascript:`, `http:`, protokollrelative Adresse, dritte Ebene) schreiben nichts. Über HTTP zusätzlich: anonymer Schreibversuch, CSRF, veraltete Version, Aufbau des Baums aus flachen Formularzeilen mit Position und Ebene, normalisiertes Sprungziel und die Bestätigung, dass die Navigation nicht mehr aus dem Template stammt.
|
||||
- Plugin-Erhalt geprüft: ein v4-Dokument mit `acme.slider` samt verschachteltem Text wird Byte für Byte gespeichert, veröffentlicht und wieder geladen; weder Props noch Kindinhalte noch eine `javascript:`-URL erscheinen in der öffentlichen Ausgabe, die übrige Seite rendert normal. Fehlerhafte Namensräume und Plugin-Blöcke in v1 bis v3 werden weiterhin abgewiesen, ohne zu schreiben. Im JavaScript-Modell zusätzlich: Verschieben, Duplizieren mit neuen IDs, unverändertes Undo, kein Einfügen in den Baustein und keine Erzeugung über die Elementbibliothek.
|
||||
- Testumgebung: Twig lädt Vorlagen jetzt automatisch neu. Zuvor konnte eine veraltete kompilierte Vorlage im Cache den Ausgang des Testlaufs bestimmen; der Fehler wurde beim Schreiben des Plugin-Tests sichtbar und die Behebung mit einer bewusst veränderten Vorlage gegengeprüft.
|
||||
- Testumgebung: `tests/bootstrap.php` verwirft `var/cache/test` einmal pro Lauf. Ohne Debug prüft Symfony weder den kompilierten Container noch die Vorlagen auf Aktualität; das hat zweimal ein falsches Ergebnis erzeugt (eine Vorlagenänderung wurde übergangen, später schlug ein geänderter Konstruktor aus einem veralteten Container fehl). Zusätzlich lädt Twig in der Testumgebung automatisch neu.
|
||||
- Migration `20260912000500` zuerst gegen eine Kopie der lokalen Datenbank ausgeführt, dann nach Backup unter `var/backups/before-menus-*.db` lokal angewendet. Seeding legt fehlende Menüs an und lässt vorhandene unverändert; zweimal hintereinander ausgeführt.
|
||||
- Migration `20260912000400` zuerst gegen eine Kopie der lokalen Datenbank geprüft und die erzeugten Anweisungen kontrolliert: vier reine `ALTER TABLE … ADD COLUMN`, kein Tabellenneuaufbau. Danach nach konsistentem Backup unter `var/backups/before-hierarchy-20260912-102402.db` lokal angewendet; Backfill, Indexumstellung und unveränderte Dokumente überprüft. Wiederholtes Seeding und Produktionsbuild erfolgreich.
|
||||
- Composer-/Format-/Twig-/YAML-/Containerprüfungen, Migration, wiederholtes Seeding und Produktionsbuild erfolgreich.
|
||||
- Frische Quellkopie ohne bestehende DB/Secrets/Dev-Abhängigkeiten erfolgreich eingerichtet; gespeicherte Seite als HTTP 200 gerendert.
|
||||
@@ -52,6 +55,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- Menüs kennen nur die beiden festen Plätze Hauptnavigation und Fußzeile; weitere Plätze, eigene Menüs pro Seite und automatisch aus der Hierarchie erzeugte Einträge gibt es nicht. Die Einträge werden über Position und Ebene sortiert, nicht per Drag-and-drop.
|
||||
- Seiten lassen sich anlegen und löschen, aber noch nicht umbenennen oder verschieben: eine Adressänderung müsste die Pfade aller Unterseiten mitziehen und Weiterleitungen anlegen. Beiträge werden gespeichert und ausgegeben, haben aber noch keine Übersichtsseite, keine Sortierung nach Datum und keine strukturierten Daten. Die Hauptnavigation ist weiterhin fest im Template; verschachtelte Seiten erscheinen dort nicht automatisch.
|
||||
- Für diesen Schritt wurden bewusst keine Browser-, Lighthouse- oder axe-Läufe ausgeführt (Nutzerentscheidung). Oberfläche und Ausgabe der neuen Formulare sind nur über die Funktionstests geprüft, nicht visuell.
|
||||
- SQLite tatsächlich getestet. MySQL-/MariaDB-Kompatibilität und CI erst vorbereitet, nicht nachgewiesen; Anbieter-/Tarifdetails und Livebetrieb offen.
|
||||
@@ -62,7 +66,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Arbeitspaket A aus `docs/AUSBAUPLAN.md`: Menüverwaltung mit Seiten-, Anker- und externen Zielen, die die fest im Template stehende Navigation ersetzt. Danach Paket B (Beiträge) und C (die Blöcke, die die Pilotstartseite braucht).
|
||||
Arbeitspaket B aus `docs/AUSBAUPLAN.md`: Beiträge mit Datum, Autor, Auszug und Beitragsbild, Übersichtsseite mit Paginierung, einbettbare Beitragsliste, Kategorien und Schlagwörter samt Archiven sowie RSS. Danach Paket C mit den Blöcken, die die Startseite des Piloten braucht.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912000500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Named menus per slot, stored as a validated item tree.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$menu = $schema->createTable('menu');
|
||||
$menu->addColumn('id', 'string', ['length' => 64]);
|
||||
$menu->addColumn('website_id', 'string', ['length' => 64]);
|
||||
$menu->addColumn('locale', 'string', ['length' => 12]);
|
||||
// One menu per slot and locale; the slot is what a template asks for.
|
||||
$menu->addColumn('slot', 'string', ['length' => 32]);
|
||||
$menu->addColumn('name', 'string', ['length' => 120]);
|
||||
$menu->addColumn('items', 'json');
|
||||
// Optimistic locking, same contract as pages: a stale form must not overwrite newer items.
|
||||
$menu->addColumn('version', 'integer', ['default' => 0]);
|
||||
$menu->setPrimaryKey(['id']);
|
||||
$menu->addUniqueIndex(['website_id', 'locale', 'slot'], 'uniq_menu_slot');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Contains the site navigation. Restore a verified database backup with matching code.');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit bootstrap="vendor/autoload.php" colors="true" cacheDirectory="phpunit.cache" failOnWarning="true" failOnRisky="true">
|
||||
<phpunit bootstrap="tests/bootstrap.php" colors="true" cacheDirectory="phpunit.cache" failOnWarning="true" failOnRisky="true">
|
||||
<testsuites><testsuite name="CMS"><directory>tests</directory></testsuite></testsuites>
|
||||
</phpunit>
|
||||
@@ -13,3 +13,5 @@
|
||||
|
||||
.notice.ok{background:#edf3dc;color:#315b49;border:1px solid #c5d89a}.notice{margin-top:24px}.create-panel{margin-top:34px;background:white;border:1px solid var(--line);border-radius:10px}.create-panel>summary{padding:20px 24px;font-size:13px;font-weight:600;cursor:pointer;border-radius:10px}.create-panel[open]>summary{border-bottom:1px solid var(--line)}.create-form{padding:6px 24px 26px}.create-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:0 24px}.create-form .primary{margin-top:24px}.create-form code{background:#f0f4e9;padding:1px 5px;border-radius:4px}.delete-panel>summary{font-size:12px;font-weight:600;color:#943a2e;cursor:pointer;padding:11px 13px;border:1px solid var(--line);border-radius:7px;list-style:none}.delete-panel[open]{position:relative}.delete-panel[open]>form{position:absolute;right:0;top:calc(100% + 8px);z-index:2;width:290px;background:white;border:1px solid var(--line);border-radius:10px;padding:18px;box-shadow:0 14px 40px #203b3422}.delete-panel p{font-size:12px;line-height:1.6;color:var(--muted)}
|
||||
@media (max-width:720px){.page-card{flex-wrap:wrap}.pages-main{margin-block:40px}.delete-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}}
|
||||
|
||||
.admin-links{display:flex;flex-wrap:wrap;gap:10px;margin:0}.menu-section{margin-top:46px}.menu-form{background:white;border:1px solid var(--line);border-radius:10px;padding:8px 24px 26px}.menu-form>label:first-of-type{margin-top:18px}.menu-form>input[name=name]{max-width:420px}.menu-table{width:100%;border-collapse:collapse;margin-top:24px;display:block;overflow-x:auto}.menu-table th{text-align:left;font-size:11px;letter-spacing:.4px;color:var(--muted);padding:0 8px 8px;font-weight:700;white-space:nowrap}.menu-table td{padding:4px 8px 4px 0;vertical-align:top}.menu-table input,.menu-table select{font-size:12px;min-height:38px;padding:8px 10px}.menu-table td:first-child input{width:78px}.menu-table td:nth-child(2) select{width:140px}.menu-table td:nth-child(3) input{min-width:170px}.menu-table td:nth-child(4) select{width:104px}.menu-table td:nth-child(5) select{min-width:190px}.menu-table td:nth-child(6) input{width:130px}.menu-table td:nth-child(7) input{min-width:190px}.menu-row-new input,.menu-row-new select{background:#fafbf8}.menu-form .primary{margin-top:20px}.menu-form code{background:#f0f4e9;padding:1px 5px;border-radius:4px}
|
||||
@@ -4,3 +4,14 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{display:flex;justify-content:space-between;align-items:center;min-height:106px;border-bottom:1px solid var(--line);gap:24px}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;padding-block:34px;border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header{min-height:100px;align-items:flex-start;flex-direction:column;justify-content:center;gap:18px;padding-block:22px}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
.menu-list a{text-decoration:none;padding:6px 2px;display:inline-block}
|
||||
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:6px}
|
||||
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
|
||||
.menu-branch>summary::after{content:' ▾'}
|
||||
.menu-branch[open]>summary::after{content:' ▴'}
|
||||
.menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
|
||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
{% block title %}Menüs{% endblock %}
|
||||
{% block body %}
|
||||
<header class="admin-header">
|
||||
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
||||
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
|
||||
</header>
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">NAVIGATION</p><h1>Menüs</h1>
|
||||
<p class="muted">Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.</p>
|
||||
<a class="secondary" href="{{ path('admin_pages') }}">Zurück zu den Seiten</a>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
{% endfor %}{% endfor %}
|
||||
|
||||
{% for slot, slotName in slots %}
|
||||
{% set menu = menus[slot]|default(null) %}
|
||||
<section class="menu-section">
|
||||
<div class="section-heading"><h2>{{ slotName }} <span class="count">{{ rows[slot]|length }}</span></h2><span class="muted">Platz <code>{{ slot }}</code></span></div>
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" class="menu-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
|
||||
<label for="name-{{ slot }}">Name des Menüs</label>
|
||||
<input id="name-{{ slot }}" name="name" maxlength="120" required value="{{ menu ? menu.name : slotName }}">
|
||||
|
||||
<table class="menu-table">
|
||||
<caption class="visually-hidden">Einträge im Menü {{ slotName }}. Eine Beschriftung zu leeren entfernt den Eintrag.</caption>
|
||||
<thead><tr><th scope="col">Position</th><th scope="col">Ebene</th><th scope="col">Beschriftung</th><th scope="col">Ziel</th><th scope="col">Seite</th><th scope="col">Sprungziel</th><th scope="col">Adresse</th></tr></thead>
|
||||
<tbody>
|
||||
{% for index, row in rows[slot]|merge([{label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.label == '' %} class="menu-row-new"{% endif %}>
|
||||
<td><input type="number" name="items[{{ n }}][position]" value="{{ (n + 1) * 10 }}" min="0" max="9999" step="1" aria-label="Position von Eintrag {{ n + 1 }}"></td>
|
||||
<td>
|
||||
<select name="items[{{ n }}][level]" aria-label="Ebene von Eintrag {{ n + 1 }}">
|
||||
<option value="1"{{ row.level == 1 ? ' selected' : '' }}>1 — oben</option>
|
||||
<option value="2"{{ row.level == 2 ? ' selected' : '' }}>2 — Untereintrag</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="items[{{ n }}][label]" maxlength="80" value="{{ row.label }}" aria-label="Beschriftung von Eintrag {{ n + 1 }}" placeholder="{{ row.label == '' ? 'Neuer Eintrag' : '' }}"></td>
|
||||
<td>
|
||||
<select name="items[{{ n }}][type]" aria-label="Zielart von Eintrag {{ n + 1 }}">
|
||||
<option value="page"{{ row.type == 'page' ? ' selected' : '' }}>Seite</option>
|
||||
<option value="url"{{ row.type == 'url' ? ' selected' : '' }}>Adresse</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="items[{{ n }}][pageId]" aria-label="Seite von Eintrag {{ n + 1 }}">
|
||||
<option value="">— Seite wählen —</option>
|
||||
{% for page in pages %}<option value="{{ page.id }}"{{ row.pageId == page.id ? ' selected' : '' }}>{{ page.title }} (/{{ page.path == 'home' ? '' : page.path }})</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" aria-label="Sprungziel von Eintrag {{ n + 1 }}" placeholder="kontakt"></td>
|
||||
<td><input name="items[{{ n }}][url]" maxlength="2048" value="{{ row.url }}" aria-label="Adresse von Eintrag {{ n + 1 }}" placeholder="https://…"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Ein Sprungziel gilt zusammen mit der gewählten Seite und führt zu <code>/seite#sprungziel</code>. Als Adresse sind HTTPS-Adressen, lokale Pfade sowie <code>mailto:</code> und <code>tel:</code> erlaubt. Die letzten drei Zeilen sind für neue Einträge; beim Speichern werden sie neu durchnummeriert.</p>
|
||||
<button class="primary" type="submit">{{ slotName }} speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
|
||||
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
|
||||
<a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a>
|
||||
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a></p>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{# One level of a resolved menu. Dropdowns use details/summary so submenus work without JavaScript. #}
|
||||
<ul class="menu-list menu-level-{{ level|default(1) }}">
|
||||
{% for link in links %}
|
||||
<li>
|
||||
{% if link.children is not empty %}
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -15,15 +15,19 @@
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
<header class="site-header shell">
|
||||
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<a href="/"{% if page.slug == 'home' %} aria-current="page"{% endif %}>Überblick</a>
|
||||
<a href="/grundlagen"{% if page.slug == 'grundlagen' %} aria-current="page"{% endif %}>Grundlagen</a>
|
||||
<a class="nav-link" href="/projektstand"{% if page.slug == 'projektstand' %} aria-current="page"{% endif %}>Projektstand <span aria-hidden="true">↗</span></a>
|
||||
</nav>
|
||||
{% if navigation.header|default([]) is not empty %}
|
||||
<nav aria-label="Hauptnavigation">{% include 'nav.html.twig' with {links: navigation.header} only %}</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main id="main" tabindex="-1">
|
||||
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false)} only %}{% endfor %}
|
||||
</main>
|
||||
<footer class="site-footer shell"><a class="brand" href="/">kant<span class="brand-light">cms</span></a><p>Eine Grundlage. Viele Möglichkeiten.</p><span>Lokaler Entwicklungsprototyp · 2026</span></footer>
|
||||
<footer class="site-footer shell">
|
||||
<a class="brand" href="/">kant<span class="brand-light">cms</span></a>
|
||||
{% if navigation.footer|default([]) is not empty %}
|
||||
<nav aria-label="Fußzeile">{% include 'nav.html.twig' with {links: navigation.footer} only %}</nav>
|
||||
{% endif %}
|
||||
<span>Lokaler Entwicklungsprototyp · 2026</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,10 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
@@ -14,6 +16,7 @@ use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -24,6 +27,7 @@ final class CmsTest extends TestCase
|
||||
{
|
||||
private Connection $db;
|
||||
private PageRepository $pages;
|
||||
private MenuRepository $menus;
|
||||
private string $path;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -33,6 +37,7 @@ final class CmsTest extends TestCase
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
@@ -42,6 +47,7 @@ final class CmsTest extends TestCase
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
}
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator());
|
||||
$this->menus = new MenuRepository($this->db);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@@ -374,6 +380,122 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
|
||||
}
|
||||
|
||||
public function testMenuRendersPageAnchorAndExternalTargetsWithCurrentMarker(): void
|
||||
{
|
||||
$about = $this->publishedPage('ueber-uns', 'Ueber uns');
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => [
|
||||
['label' => 'Kontakt', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => 'kontakt', 'children' => []],
|
||||
]],
|
||||
['label' => 'Ueber uns', 'type' => 'page', 'pageId' => $about, 'anchor' => '', 'children' => []],
|
||||
['label' => 'HausKlar', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
||||
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972219957150', 'children' => []],
|
||||
], 0);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('href="/#kontakt"', $html);
|
||||
self::assertStringContainsString('href="/ueber-uns"', $html);
|
||||
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
||||
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
||||
// The home link is the current page; an anchor inside it is not.
|
||||
self::assertSame(1, substr_count($html, 'aria-current="page"'));
|
||||
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $html);
|
||||
|
||||
// On another page the marker moves and nothing else changes.
|
||||
$other = $this->request('/ueber-uns')->getContent();
|
||||
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
|
||||
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
||||
}
|
||||
|
||||
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
|
||||
{
|
||||
$doomed = $this->publishedPage('temporaer', 'Temporaer');
|
||||
$draft = $this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Verschwindet', 'type' => 'page', 'pageId' => $doomed, 'anchor' => '', 'children' => [
|
||||
['label' => 'Bleibt als Kind', 'type' => 'url', 'url' => 'https://example.com', 'children' => []],
|
||||
]],
|
||||
['label' => 'Noch Entwurf', 'type' => 'page', 'pageId' => $draft, 'anchor' => '', 'children' => []],
|
||||
['label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app', 'children' => []],
|
||||
], 0);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$this->pages->deletePage($doomed, $this->pages->findForEditor($doomed)['version']);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringNotContainsString('Verschwindet', $html);
|
||||
// An unpublished page must not appear in public navigation.
|
||||
self::assertStringNotContainsString('Noch Entwurf', $html);
|
||||
self::assertStringNotContainsString('/entwurf', $html);
|
||||
// The rest of the menu is intact, including the child of the removed branch.
|
||||
self::assertStringContainsString('Bleibt als Kind', $html);
|
||||
self::assertStringContainsString('href="https://hausklar.app"', $html);
|
||||
// The stored items are untouched: publishing the draft brings its entry back.
|
||||
self::assertCount(3, $this->menus->all()['header']['items']);
|
||||
$page = $this->pages->findForEditor($draft);
|
||||
$this->pages->publish($draft, $page['draft_revision_id'], $page['version']);
|
||||
self::assertStringContainsString('Noch Entwurf', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array}>
|
||||
*/
|
||||
public static function rejectedMenuItems(): iterable
|
||||
{
|
||||
$page = static fn (array $extra = []): array => [...['label' => 'Eintrag', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []], ...$extra];
|
||||
yield 'empty label' => [[$page(['label' => ' '])]];
|
||||
yield 'oversized label' => [[$page(['label' => str_repeat('a', 81)])]];
|
||||
yield 'unknown type' => [[$page(['type' => 'script'])]];
|
||||
yield 'missing page' => [[$page(['pageId' => ''])]];
|
||||
yield 'anchor with hash' => [[$page(['anchor' => 'a#b'])]];
|
||||
yield 'anchor starting with digit' => [[$page(['anchor' => '1kontakt'])]];
|
||||
yield 'javascript url' => [[['label' => 'X', 'type' => 'url', 'url' => 'javascript:alert(1)', 'children' => []]]];
|
||||
yield 'plain http url' => [[['label' => 'X', 'type' => 'url', 'url' => 'http://example.com', 'children' => []]]];
|
||||
yield 'protocol relative url' => [[['label' => 'X', 'type' => 'url', 'url' => '//evil.example', 'children' => []]]];
|
||||
yield 'third level' => [[$page(['children' => [$page(['children' => [$page()]])]])]];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedMenuItems')]
|
||||
public function testRejectsInvalidMenuItemsWithoutWriting(array $items): void
|
||||
{
|
||||
try {
|
||||
$this->menus->save('header', 'Hauptnavigation', $items, 0);
|
||||
self::fail('Expected the menu to be rejected.');
|
||||
} catch (InvalidMenu) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||||
}
|
||||
|
||||
public function testMenuRejectsUnknownSlotOversizeAndStaleWrites(): void
|
||||
{
|
||||
$item = ['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []];
|
||||
try {
|
||||
$this->menus->save('sidebar', 'Seitenleiste', [$item], 0);
|
||||
self::fail('Expected an unknown slot to be rejected.');
|
||||
} catch (InvalidMenu) {
|
||||
}
|
||||
try {
|
||||
$this->menus->save('header', 'Hauptnavigation', array_fill(0, MenuRepository::MAX_ITEMS + 1, $item), 0);
|
||||
self::fail('Expected the item limit to apply.');
|
||||
} catch (InvalidMenu) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||||
|
||||
$this->menus->save('header', 'Hauptnavigation', [$item], 0);
|
||||
self::assertSame(1, $this->menus->all()['header']['version']);
|
||||
$this->expectException(StaleWrite::class);
|
||||
$this->menus->save('header', 'Geaendert', [$item], 0);
|
||||
}
|
||||
|
||||
private function publishedPage(string $slug, string $title): string
|
||||
{
|
||||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::starter($title));
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
@@ -14,6 +15,7 @@ use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
@@ -44,6 +46,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
@@ -101,7 +104,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testAnonymousCannotReadDraftOrWrite(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media'] as $url) {
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus'] as $url) {
|
||||
$client->request('GET', $url);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
|
||||
@@ -110,7 +113,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$this->post($client, $action, []);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
}
|
||||
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]]] as [$url, $body]) {
|
||||
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]]] as [$url, $body]) {
|
||||
$client->request('POST', $url, $body);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
}
|
||||
@@ -160,6 +163,68 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
// Rows are ordered by position and nested by level, so the form needs no JavaScript.
|
||||
$client->request('POST', '/admin/menus/header', [
|
||||
'_csrf_token' => $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'),
|
||||
'name' => 'Hauptnavigation',
|
||||
'expectedVersion' => 0,
|
||||
'items' => [
|
||||
['position' => 30, 'level' => 1, 'label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app'],
|
||||
['position' => 10, 'level' => 1, 'label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''],
|
||||
['position' => 20, 'level' => 2, 'label' => 'Kontakt', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '#kontakt'],
|
||||
['position' => 40, 'level' => 1, 'label' => ' ', 'type' => 'page', 'pageId' => 'p1'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
|
||||
$items = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true);
|
||||
self::assertSame(['Start', 'Extern'], array_column($items, 'label'));
|
||||
self::assertSame('Kontakt', $items[0]['children'][0]['label']);
|
||||
// A leading hash in the anchor field is accepted and normalised away.
|
||||
self::assertSame('kontakt', $items[0]['children'][0]['anchor']);
|
||||
// The blank row is an add slot, not an entry.
|
||||
self::assertCount(2, $items);
|
||||
|
||||
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] a');
|
||||
// The navigation comes from the menu now, not from the links hard-coded in the template.
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
|
||||
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db);
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
|
||||
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => 'wrong', 'name' => 'X', 'expectedVersion' => 1, 'items' => [$entry]]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
// A stale version must not overwrite a newer menu.
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Veraltet', 'expectedVersion' => 0, 'items' => [$entry]]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
// An unsafe target is refused with a message instead of being stored.
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [['position' => 10, 'level' => 1, 'label' => 'Boese', 'type' => 'url', 'url' => 'javascript:alert(1)']]]);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorExists('.notice.error');
|
||||
self::assertStringNotContainsString('javascript:', $this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']));
|
||||
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [$entry]]);
|
||||
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
}
|
||||
|
||||
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
// The test kernel runs with debug off, so Symfony does not check whether the compiled container or
|
||||
// the compiled templates are still current. A changed constructor or template would otherwise be
|
||||
// answered from a stale cache and make the suite report something that is not the code under test.
|
||||
// Dropping the test cache once per run costs about a second and removes that whole class of
|
||||
// false results.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
$cache = dirname(__DIR__).'/var/cache/test';
|
||||
if (is_dir($cache)) {
|
||||
$entries = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($cache, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($entries as $entry) {
|
||||
$entry->isDir() ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname());
|
||||
}
|
||||
@rmdir($cache);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
@@ -26,6 +27,7 @@ $schema = new Schema();
|
||||
(new Version20260912000200($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user