Kopf, Fuß und verknüpfte Sektionen als Blockdokumente (Paket G, Teil 1)

Migration 20260912001000 legt die Tabelle partial an: Blockdokumente,
die keine Seiten sind. Kopf- und Fußbereich je einmal,
wiederverwendbare Sektionen beliebig viele - im selben Editor
gestaltbar wie eine Seite, weil der Editor seine Schnittstelle aus
einem Datenattribut nimmt statt sie fest zu kennen.

Im Template steht keine Marke und keine Navigation mehr. Ein neuer
Block menu zeigt einen Menüplatz; die Einträge bleiben in der
Menüverwaltung.

Eine Seite braucht genau eine Hauptüberschrift, ein geteilter Bereich
keine - sonst hätte jede Seite, die ihn trägt, zwei. Geteilte
Bereiche haben keinen Entwurf und keine Veröffentlichung: sie sind
überall sichtbar, gespeichert heißt live.

Verknüpft statt kopiert: partialref speichert nur die Adresse, die
Blöcke bleiben im Bereich, und eine Änderung erreicht jede Seite, die
sie einbindet. Zyklen abgewiesen, Verschachtelung auf drei Ebenen
begrenzt, eine entfernte Sektion hinterlässt keine Lücke und der
Verweis bleibt erhalten. Die Abschnittsvorlagen kopieren weiterhin.

Ein echter Fehler kam dabei heraus, der jede dunkle Sektion mit
Spalten betraf: .block-column>.block-text:first-child setzt eine
Farbe für helle Hintergründe und gewinnt auf Spezifität gegen
.tone-ink - gemessen 1,87:1.

Geprüft: 165 PHP-Tests / 660 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Editortest mit axe ohne Verstöße,
öffentliche Browserprüfung, Migration nach Backup angewendet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 16:38:08 +02:00
1 parent 33e4ac0c54
commit d0938eec69
26 files changed
+955 -56

No files matched your search

+14
View File
@@ -302,3 +302,17 @@ Die gewählte Seite muss existieren, veröffentlicht und nicht im Papierkorb sei
Ein Kontrastfehler hat zwei sehr verschiedene Ursachen, und sie brauchen verschiedene Antworten. Kommt er aus dem Theme oder einem Stylesheet, ist es ein Defekt des Systems und der Lauf muss fehlschlagen – genau das hat die Linkfarbe aufgedeckt, die jeden dunklen Abschnitt überschrieb. Kommt er aus einer Farbe, die jemand im Editor an einem einzelnen Block gesetzt hat, ist es ein redaktionelles Thema: im Code nicht behebbar, und einen Codewechsel dafür rot zu färben würde nur dazu führen, dass die Prüfung irgendwann abgeschaltet wird.
`scripts/browser-check.mjs` unterscheidet deshalb: ein Kontrastverstoß, dessen Element eine Regel im erzeugten Seiten-Stylesheet hat, wird als redaktionelle Feststellung bei **jedem** Lauf mit Seite, Farbe und Verhältnis ausgegeben und in `reports/browser.json` geschrieben. Alles andere bleibt ein Defekt und bricht den Lauf ab. Stillschweigend durchgelassen wird nichts.
## ADR 026 – Kopfbereich, Fußbereich und verknüpfte Sektionen (Dokumentschema v11, 12.09.2026)
Migration `20260912001000` legt die Tabelle `partial` an: Blockdokumente, die keine Seiten sind. Kopf- und Fußbereich gibt es je einmal, wiederverwendbare Sektionen beliebig viele. Sie nutzen dieselben Blockverträge, denselben Validator und **denselben Editor** wie eine Seite – der Editor nimmt seine Schnittstelle aus einem Datenattribut, sodass es keine zweite Umsetzung gibt.
Ein Unterschied bleibt: eine Seite braucht **genau eine** Hauptüberschrift, ein geteilter Bereich **keine**. Sonst hätte jede Seite, die ihn trägt, zwei. Der Validator kennt dafür einen Schalter, und beide Fälle sind geprüft.
Geteilte Bereiche haben **keinen Entwurf und keine Veröffentlichung**. Sie erscheinen auf jeder Seite; ein zweiter Stand wäre nur die Frage, welche Seite welchen sieht. Gespeichert heißt live, und der Editor lässt die Veröffentlichen-Schaltfläche für sie weg. Optimistische Konflikterkennung gilt wie überall.
**Verknüpft statt kopiert.** Der Block `partialref` speichert nur die Adresse der Sektion; ihre Blöcke bleiben im Bereich. Eine Änderung erreicht damit jede Seite, die sie einbindet – das ist der Unterschied zu den Abschnittsvorlagen, die weiterhin *kopieren*. Beides hat seinen Platz, und der Plan verlangt genau diese Unterscheidung. Zyklen werden beim Speichern abgewiesen, die Verschachtelung ist auf drei Ebenen begrenzt, und eine entfernte Sektion hinterlässt einen HTML-Kommentar statt einer Lücke; im Editor erscheint ein Platzhalter. Der Verweis bleibt gespeichert, sodass eine wiederhergestellte Sektion sofort wieder erscheint.
Damit ein Kopfbereich überhaupt frei baubar ist, gibt es einen Block `menu`: er zeigt einen Menüplatz, dessen Einträge weiterhin in der Menüverwaltung liegen. Die Marke ist ein gewöhnlicher Text- oder Bildblock. Im Template steht keine Navigation und keine Marke mehr.
**Ein echter Fehler kam dabei heraus, der nicht nur den eingesetzten Fußbereich betraf:** die Token-CSS war für die flache Struktur Sektion → Text geschrieben. `.block-column>.block-text:first-child` setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen `.tone-ink .block-text`. Text in einer Spalte auf dunklem Grund war damit unlesbar – gemessen 1,87:1. Zwei gezielte Selektoren gleicher Spezifität, später in der Datei, stellen das richtig; helle Farbwelten bleiben unberührt.
+11
View File
@@ -122,6 +122,17 @@ Inline-Textbearbeitung direkt in der Vorschau. Globale Farben und Schriften. Hea
Abnahme: Die Pilotwebsite lässt sich vollständig ohne Code pflegen. Eine geänderte globale Farbe wirkt auf allen Seiten.
### G2 – Freie Bereiche, Rich Text und eigener Code (Nutzerauftrag vom 12.09.2026)
Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
1. **Kopf- und Fußbereich frei aus Elementen bauen**, genau wie eine Sektion – keine feste Marke, keine feste Navigation im Template. Dazu wiederverwendbare Sektionen, die *verknüpft* und nicht kopiert werden: eine Änderung erreicht jede Seite, die sie nutzt.
2. **Rich Text in Textelementen**: fett, kursiv, Link, Liste und Ähnliches, plus eine **HTML-Ansicht** zum direkten Bearbeiten der Auszeichnung. Gespeichert wird eingeschränktes HTML, serverseitig gegen eine Positivliste gefiltert – der Editor darf nie entscheiden, was erlaubt ist.
3. **Eigenes CSS und eigenes Skript je Element.** Eigenes CSS ist unkritisch und für alle mit Editorrolle nutzbar. **Eigenes Skript ist es nicht:** es ist gespeichertes, ausführbares JavaScript auf der Website. Wer es setzen darf, kann Code im Browser jedes Administrators ausführen. Deshalb: nur für Administratoren, Ausgabe über eine seiteneigene Skriptdatei statt inline, keine Ausführung in der Editorvorschau, und `script-src 'self'` nur auf Seiten, die tatsächlich ein Skript tragen. Der Preis steht in der Oberfläche.
4. **Verwaltung neu gestalten** – einfacher, schöner, ohne Nachdenken bedienbar. Gestaltungsfreiheit ausdrücklich bei mir; der bisherige Stil ist nicht bindend. Das betrifft Navigation zwischen den Bereichen, die Seitenliste, die Panels im Editor und die Formularseiten, die derzeit alle als lange Formulartabellen gebaut sind.
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
### H – Plugins und Templates
*Braucht: den Erhaltungsvertrag (erledigt).*
+8
View File
@@ -1,5 +1,13 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Kopf, Fuß und verknüpfte Sektionen, 12.09.2026
- **165 PHP-Tests / 660 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests**, der vollständige Playwright-Editortest mit axe (null Verstöße) und die öffentliche Browserprüfung.
- Bereiche geprüft: Kopf- und Fußbereich erscheinen als gewöhnliche Blöcke, der Navigationsblock zeigt das Menü, eine Änderung am Kopfbereich erreicht beide geprüften Seiten gleichzeitig, und ein geleerter Bereich rendert nichts statt einer kaputten Hülle.
- Verknüpfte Sektion geprüft: auf zwei Seiten sichtbar, die Seitendokumente enthalten **nur die Adresse**, eine Änderung wirkt an beiden Stellen, `usedBy` nennt beide Seiten, und nach dem Entfernen bleiben beide Seiten mit HTTP 200 heil, mit erhaltenem Verweis.
- Abgewiesen werden: eine Hauptüberschrift in einem geteilten Bereich, eine Sektion mit Selbstbezug (ohne zu schreiben), ein zweiter Kopfbereich und das Löschen von Kopf- oder Fußbereich.
- **Ein echter Fehler, den der axe-Lauf gefunden hat, und zwar nicht nur im eingesetzten Fußbereich:** die Token-CSS war für die flache Struktur Sektion → Text geschrieben. `.block-column>.block-text:first-child` setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen `.tone-ink .block-text`; Text in einer Spalte auf dunklem Grund war damit unlesbar, gemessen **1,87:1**. Das betraf jede dunkle Sektion mit Spalten, nicht nur die neuen Bereiche. Zwei gezielte Selektoren gleicher Spezifität stellen es richtig, helle Farbwelten bleiben unberührt.
## Ergänzung: Startseite, erreichbare Standarddateien, getrennte Befunde, 12.09.2026
- **162 PHP-Tests / 636 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests**, der vollständige Playwright-Editortest und die **wieder grüne öffentliche Browserprüfung**.
+7 -3
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). 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.
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). 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,6 +25,8 @@ 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.
- **Kopf- und Fußbereich sind Blockdokumente** unter `/admin/partials`, im selben Editor gestaltbar wie eine Seite. Im Template steht keine Marke und keine Navigation mehr; ein neuer Block `menu` zeigt einen Menüplatz. Geteilte Bereiche haben keinen Entwurf und keine Veröffentlichung – gespeichert heißt live – und dürfen keine Hauptüberschrift enthalten, weil jede Seite ihre eigene bringt.
- **Wiederverwendbare Sektionen werden verknüpft, nicht kopiert** (Block `partialref`, Dokumentschema v11): die Seite speichert nur die Adresse, eine Änderung erreicht jede Seite, die sie einbindet. Zyklen werden abgewiesen, Verschachtelung auf drei Ebenen begrenzt, eine entfernte Sektion hinterlässt keine Lücke und der Verweis bleibt gespeichert. Die Abschnittsvorlagen kopieren weiterhin – beide Wege stehen bewusst nebeneinander.
- **Frei wählbare Startseite** unter Einstellungen. Es gibt jetzt eine Quelle dafür – `SiteSettings::homePath()` und `publicPath()`; das Literal `home` kommt im Anwendungscode nicht mehr vor. Die gewählte Seite muss veröffentlicht und nicht im Papierkorb sein, ihre eigene Adresse leitet dauerhaft auf `/`, und solange sie die Wurzel ist, lässt sie sich nicht umbenennen oder löschen.
- **`/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` waren lokal nie erreichbar**, obwohl jeder Kernel-Test sie bestand: der eingebaute PHP-Server antwortet für fehlende Pfade mit Dateiendung selbst mit 404. `bin/dev-router.php` bildet die Rewrite-Regel des Hostings nach; Start- und CI-Befehl nutzen es, und der Browsertest ruft die vier Adressen jetzt über HTTP auf.
- **Barrierefreiheitsbefunde werden nach Ursache getrennt:** ein Kontrastfehler aus Theme oder Stylesheet bricht den Lauf ab, einer aus einer im Editor gesetzten Blockfarbe wird bei jedem Lauf mit Seite, Farbe und Verhältnis ausgegeben und in `reports/browser.json` geschrieben. Stillschweigend durchgelassen wird nichts.
@@ -56,8 +58,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Geprüft
- 162 PHP-Tests / 636 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. 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.
- 165 PHP-Tests / 660 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. 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.
- Bereiche geprüft: die eingesetzten Kopf- und Fußbereiche erscheinen als gewöhnliche Blöcke, der Navigationsblock zeigt das Menü, und eine Änderung am Kopfbereich erreicht beide Seiten gleichzeitig. Ein leerer Bereich rendert nichts statt einer kaputten Hülle. Verknüpfte Sektion geprüft: auf zwei Seiten sichtbar, die Seitendokumente enthalten nur die Adresse, eine Änderung wirkt an beiden Stellen, `usedBy` nennt beide Seiten, und nach dem Entfernen bleiben die Seiten heil und der Verweis erhalten. Abgewiesen werden: Hauptüberschrift in einem geteilten Bereich, Sektion mit Selbstbezug, zweiter Kopfbereich und das Löschen von Kopf- oder Fußbereich.
- Startseite geprüft: eine andere veröffentlichte Seite wird zur Wurzel, antwortet unter `/`, ihre eigene Adresse leitet mit 301 dorthin, die bisherige Wurzel wird eine gewöhnliche Seite und ist wieder verschiebbar. Sitemap, strukturierte Daten und Menüauflösung folgen derselben Entscheidung. Solange eine Seite die Wurzel ist, werden Umbenennen und Papierkorb abgewiesen. Drei ungültige Wurzelangaben (nicht vorhanden, leer, Großbuchstaben) ändern nichts.
- **Öffentliche Browserprüfung wieder grün**, inklusive der vier maschinenlesbaren Adressen über HTTP mit richtigem Inhaltstyp. Sechs Seiten-/Breitenkombinationen ohne Code-Verstoß; die redaktionelle Feststellung zu `/grundlagen` wird ausgegeben.
- **Lighthouse 13.4.1: je drei Läufe mobil und Desktop, alle fünf Kategorien 100** mit Startseite, Theme, gehashtem Stylesheet und Routerskript.
@@ -98,6 +101,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- 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.
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): Rich Text mit HTML-Ansicht in Textelementen, eigenes CSS und eigenes Skript je Element, und die Neugestaltung der Verwaltung. Die Reihenfolge und die Sicherheitsfolgen des Skriptfelds stehen dort.
- Offen: der Papierkorb wird nicht automatisch geleert, es gibt keine Aufbewahrungsfrist, und Weiterleitungen aus einem Verschiebevorgang lassen sich nur einzeln in den Einstellungen entfernen.
- Weiterhin keine Erprobung mit einem Screenreader. Bei der Direktbearbeitung wiegt das: dass der Fokus sinnvoll wandert und der Zustandswechsel angekündigt wird, ist durch axe nicht abgedeckt.
- Gestaltungsvorlagen lassen sich in der Oberfläche nur anlegen und anwenden, nicht umbenennen; das Entfernen geht bisher nur über die Schnittstelle. Es gibt keine Vorschau einer Vorlage.
@@ -117,7 +121,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Nächster konkreter Schritt
Den Editor mit einem Screenreader prüfen, danach Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
Paket G2 aus `docs/AUSBAUPLAN.md`: Rich Text mit HTML-Ansicht, eigenes CSS und Skript je Element, danach die Neugestaltung der Verwaltung. Dazu den Editor mit einem Screenreader prüfen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
`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.
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260912001000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Editable header and footer areas plus reusable sections as block documents.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
$partial = $schema->createTable('partial');
$partial->addColumn('id', 'string', ['length' => 64]);
$partial->addColumn('website_id', 'string', ['length' => 64]);
$partial->addColumn('locale', 'string', ['length' => 12]);
// 'header' and 'footer' exist once each; 'section' is a reusable block any page can link.
$partial->addColumn('kind', 'string', ['length' => 16]);
$partial->addColumn('slug', 'string', ['length' => 80]);
$partial->addColumn('name', 'string', ['length' => 120]);
$partial->addColumn('document', 'json');
$partial->addColumn('version', 'integer', ['default' => 0]);
$partial->setPrimaryKey(['id']);
$partial->addUniqueIndex(['website_id', 'locale', 'kind', 'slug'], 'uniq_partial_slug');
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Contains the header, the footer and every reusable section. Restore a verified database backup with matching code.');
}
}
+7 -4
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
@@ -46,6 +46,8 @@ export function createBlock(type) {
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
if (type === 'form') block.props = { form: '' };
if (type === 'menu') block.props = { slot: 'header' };
if (type === 'partialref') block.props = { partial: '' };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
}
@@ -58,6 +60,7 @@ export function insertBlock(document, type, selected) {
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
if (type === 'breadcrumb') upgradeDocument(document, 9);
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
@@ -99,7 +102,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -116,7 +119,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
+7 -2
View File
@@ -7,7 +7,10 @@ const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support',
const root = document.querySelector('#editor');
const $ = selector => document.querySelector(selector);
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
// Pages live under /admin/api/pages/{id}, shared areas under /admin/partials/{id}. Both offer the
// same document, draft and preview endpoints, so this file does not care which one it drives.
const api = root.dataset.api;
const editingArea = 'page' !== root.dataset.kind;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
@@ -213,6 +216,8 @@ function renderProperties() {
field('Anschrift', 'address', block.props.address || '', { multiline: true, maxLength: 200 });
}
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
if (block.type === 'menu') field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', footer: 'Fußzeile' }, help: 'Die Einträge werden unter Menüs gepflegt.' });
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
if (block.type === 'accordionitem') {
@@ -502,7 +507,7 @@ async function loadDesignSchema() {
async function load() {
busy = true; controls();
try {
const data = await request('');
const data = await request(editingArea ? '/document' : '');
if (!designTokens) {
const response = await fetch('/assets/design-tokens.json');
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
+7
View File
@@ -97,3 +97,10 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
.block-breadcrumb a{text-decoration:none}
.block-breadcrumb a:hover{text-decoration:underline}
/* The column rules above were written for a flat section > text structure and set a colour meant
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
later, so the dark tone wins where it has to. */
.tone-ink .block-column .block-text,
.tone-ink .block-column>.block-text:first-child{color:#dae3da}
+16 -1
View File
@@ -5,6 +5,7 @@ namespace App\Command;
use App\Content\DemoContent;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -14,7 +15,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, private MenuRepository $menus)
public function __construct(private Connection $db, private PageRepository $pages, private MenuRepository $menus, private PartialRepository $partials)
{
parent::__construct();
}
@@ -37,6 +38,7 @@ final class SeedDemoCommand extends Command
$output->writeln('Created: '.$slug);
}
$this->seedMenus($output);
$this->seedAreas($output);
});
return Command::SUCCESS;
@@ -72,4 +74,17 @@ final class SeedDemoCommand extends Command
$output->writeln('Menu created: '.$slot);
}
}
/** Only creates a missing area, so an edited header is never overwritten by seeding again. */
private function seedAreas(OutputInterface $output): void
{
foreach (DemoContent::areas() as $kind => $area) {
if (null !== $this->partials->findByKind($kind)) {
$output->writeln('Area unchanged: '.$kind);
continue;
}
$this->partials->create($kind, 'default', $area['name'], $area['document']);
$output->writeln('Area created: '.$kind);
}
}
}
+46
View File
@@ -71,6 +71,52 @@ final class DemoContent
];
}
/**
* The header and footer a fresh installation starts with. They are ordinary block documents:
* everything in them can be changed, moved or removed in the editor. No main heading, because
* every page brings its own.
*
* @return array<string, array{name: string, document: array}>
*/
public static function areas(): array
{
$row = static fn (string $id, array $children): array => [
'id' => $id, 'type' => 'row',
'layout' => ['design' => ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']]],
'children' => $children,
];
$column = static fn (string $id, array $children): array => ['id' => $id, 'type' => 'column', 'children' => $children];
return [
'header' => ['name' => 'Kopfbereich', 'document' => [
'schemaVersion' => 11,
'title' => 'Kopfbereich',
'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
self::section('area-header', [
$row('area-header-row', [
$column('area-header-brand', [self::text('area-header-name', 'kant cms')]),
$column('area-header-nav', [['id' => 'area-header-menu', 'type' => 'menu', 'props' => ['slot' => 'header']]]),
]),
]),
],
]],
'footer' => ['name' => 'Fußbereich', 'document' => [
'schemaVersion' => 11,
'title' => 'Fußbereich',
'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
self::section('area-footer', [
$row('area-footer-row', [
$column('area-footer-text', [self::text('area-footer-claim', 'Eine Grundlage. Viele Möglichkeiten.')]),
$column('area-footer-nav', [['id' => 'area-footer-menu', 'type' => 'menu', 'props' => ['slot' => 'footer']]]),
]),
], 'ink'),
],
]],
];
}
private static function article(string $title, string $description, string $heading, array $sections): array
{
$blocks = [self::section('article-hero', [self::text('article-label', 'KANT CMS / ENTWICKLUNG'), self::heading('article-heading', $heading, 1)], 'mint')];
+23 -4
View File
@@ -21,11 +21,18 @@ final class DocumentValidator
public const V8_TYPES = ['form'];
/** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
public const V9_TYPES = ['breadcrumb'];
/** Schema v11: a link to a reusable section, resolved when the page is served. */
public const V11_TYPES = ['partialref', 'menu'];
public function validate(array $document): void
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
* reusable section that appears on many pages must not bring one
* of its own, or every page carrying it would have two.
*/
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -35,7 +42,10 @@ final class DocumentValidator
foreach ($document['blocks'] as $block) {
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
}
$this->require(1 === $headings, 'A page requires exactly one main heading.');
$this->require(
$requireHeading ? 1 === $headings : 0 === $headings,
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
);
}
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void
@@ -50,7 +60,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -147,6 +157,15 @@ final class DocumentValidator
if ('tabitem' === $type) {
$this->text($props['title'] ?? null, 160);
}
if ('menu' === $type) {
// The entries live in the menu administration; the block only says which slot to show.
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
}
if ('partialref' === $type) {
// Only the address of the section is content. Its blocks live in the partial, so one
// change reaches every page that links it and a page cannot alter a shared section.
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
}
if ('form' === $type) {
// Only the address is content. Fields, recipient and consent text stay in the database,
// so a stored document can never redirect a submission or change what is asked.
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
/**
* Header, footer and reusable sections – block documents that are not pages.
*
* They use the same block contracts, the same validator and the same editor as a page, so a
* section behaves everywhere the same way. What differs is the heading rule: a page needs exactly
* one main heading, while a header, a footer or a section that appears on many pages must not
* bring one of its own.
*
* A page links a section instead of copying it. Copying already exists as the section presets;
* linking means one change reaches every page that uses it, which is the whole point.
*/
final class PartialRepository
{
public const KINDS = ['header' => 'Kopfbereich', 'footer' => 'Fußbereich', 'section' => 'Wiederverwendbare Sektion'];
/** A linked section may itself link one, but not deeper – and never in a circle. */
public const MAX_NESTING = 3;
public function __construct(private Connection $db, private DocumentValidator $validator)
{
}
/** @return list<array{id: string, kind: string, slug: string, name: string, document: array, version: int}> */
public function all(string $locale = 'de'): array
{
return array_map($this->present(...), $this->db->fetchAllAssociative(
'SELECT * FROM partial WHERE website_id = ? AND locale = ? ORDER BY kind, name',
['default', $locale],
));
}
public function find(string $id): ?array
{
$row = $this->db->fetchAssociative('SELECT * FROM partial WHERE id = ? AND website_id = ?', [$id, 'default']);
return false === $row ? null : $this->present($row);
}
public function findByKind(string $kind, string $slug = 'default', string $locale = 'de'): ?array
{
$row = $this->db->fetchAssociative(
'SELECT * FROM partial WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?',
['default', $locale, $kind, $slug],
);
return false === $row ? null : $this->present($row);
}
private function present(array $row): array
{
return [
'id' => $row['id'], 'kind' => $row['kind'], 'slug' => $row['slug'], 'name' => $row['name'],
'document' => json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR),
'version' => (int) $row['version'],
];
}
public function create(string $kind, string $slug, string $name, array $document, string $locale = 'de'): string
{
if (!isset(self::KINDS[$kind])) {
throw new InvalidPage('Unbekannte Art von Bereich.');
}
$slug = strtolower(trim($slug));
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
$name = trim($name);
if ('' === $name || mb_strlen($name) > 120) {
throw new InvalidPage('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
}
// Header and footer exist once; several would be ambiguous at render time.
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
}
$this->validator->validate($document, false);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
}
$id = bin2hex(random_bytes(16));
$this->db->insert('partial', [
'id' => $id, 'website_id' => 'default', 'locale' => $locale,
'kind' => $kind, 'slug' => 'section' === $kind ? $slug : 'default', 'name' => $name,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'version' => 1,
]);
return $id;
}
public function save(string $id, array $document, int $expectedVersion): void
{
$this->validator->validate($document, false);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
$this->guardAgainstCycles($id, $document);
$changed = $this->db->executeStatement(
'UPDATE partial SET document = ?, version = version + 1 WHERE id = ? AND version = ?',
[json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), $id, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Der Bereich wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
}
});
}
public function delete(string $id): void
{
$partial = $this->find($id);
if (null === $partial) {
throw new InvalidPage('Diesen Bereich gibt es nicht mehr.');
}
if ('section' !== $partial['kind']) {
throw new InvalidPage('Kopf- und Fußbereich können nicht gelöscht werden. Leere sie stattdessen.');
}
// A page that links a removed section shows a placeholder instead of losing its layout,
// so removal is allowed – but the editor should know where it is still in use.
$this->db->executeStatement('DELETE FROM partial WHERE id = ?', [$id]);
}
/**
* Resolves a linked section for rendering.
*
* @return array{name: string, document: array}|null
*/
public function resolveSection(string $slug, string $locale = 'de'): ?array
{
$partial = $this->findByKind('section', $slug, $locale);
return null === $partial ? null : ['name' => $partial['name'], 'document' => $partial['document']];
}
/** @throws InvalidPage when the document would make a section contain itself */
private function guardAgainstCycles(string $id, array $document, int $depth = 0): void
{
$partial = $this->find($id);
if (null === $partial || 'section' !== $partial['kind']) {
return;
}
foreach ($this->referencedSlugs($document) as $slug) {
if ($slug === $partial['slug']) {
throw new InvalidPage('Eine Sektion kann sich nicht selbst enthalten.');
}
if ($depth + 1 >= self::MAX_NESTING) {
throw new InvalidPage(sprintf('Sektionen dürfen höchstens %d Ebenen tief verschachtelt werden.', self::MAX_NESTING));
}
$nested = $this->findByKind('section', $slug, $partial['locale'] ?? 'de');
if (null !== $nested) {
$this->guardAgainstCycles($id, $nested['document'], $depth + 1);
}
}
}
/** @return list<string> */
public function referencedSlugs(array $document): array
{
$slugs = [];
$walk = function (array $blocks) use (&$walk, &$slugs): void {
foreach ($blocks as $block) {
if ('partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null)) {
$slugs[] = $block['props']['partial'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return array_values(array_unique($slugs));
}
/**
* Which pages and sections link a given section. Shown before deleting, so nobody removes
* something that is still in use without knowing it.
*
* @return list<string>
*/
public function usedBy(string $slug, string $locale = 'de'): array
{
$users = [];
foreach ($this->db->fetchAllAssociative(
'SELECT p.path, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.locale = ? AND p.deleted_at IS NULL',
['default', $locale],
) as $row) {
if (in_array($slug, $this->referencedSlugs(json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR)), true)) {
$users[] = '/'.$row['path'];
}
}
foreach ($this->all($locale) as $partial) {
if ($partial['slug'] !== $slug && in_array($slug, $this->referencedSlugs($partial['document']), true)) {
$users[] = $partial['name'];
}
}
return $users;
}
}
+17 -2
View File
@@ -6,6 +6,7 @@ use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\StyleCompiler;
@@ -24,7 +25,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CsrfTokenManagerInterface $csrf)
{
}
@@ -325,12 +326,26 @@ final class EditorController extends AbstractController
$this->validator->validate($document);
$theme = $this->theme->load();
$areas = [];
foreach (['header', 'footer'] as $kind) {
$area = $this->partials->findByKind($kind);
if (null !== $area) {
$areas[$kind] = $area['document'];
}
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
}
}
$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, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
+64 -2
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
@@ -17,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
{
}
@@ -38,10 +39,15 @@ final class PageController extends AbstractController
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
$areas = $this->areas();
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra);
}
$googleFonts = $this->theme->googleFontsUrl($theme);
}
if (null === $page) {
@@ -50,7 +56,7 @@ final class PageController extends AbstractController
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
$response = $this->render('not-found.html.twig', [], new Response(status: null !== $redirect ? 410 : 404));
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
@@ -62,6 +68,8 @@ final class PageController extends AbstractController
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'breadcrumbs' => $this->breadcrumbs($page),
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
]);
@@ -240,6 +248,8 @@ final class PageController extends AbstractController
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
return $this->shell($this->render('archive.html.twig', [
'areas' => $this->areas(),
'navigation' => $this->navigation(''),
'term' => $term,
'heading' => null === $term ? 'Beiträge' : $term['name'],
'result' => $result,
@@ -291,6 +301,58 @@ final class PageController extends AbstractController
return $lists;
}
/**
* The header and footer documents. Absent areas simply render nothing, so a site without a
* header is a valid site rather than a broken one.
*
* @return array<string, array>
*/
private function areas(): array
{
$areas = [];
foreach (['header', 'footer'] as $kind) {
$partial = $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
}
}
return $areas;
}
/**
* Resolves every linked section a page or one of its areas refers to, including sections that
* link further sections. A missing section is left out; the renderer shows a placeholder only
* in the editor, so a visitor never sees a gap explained.
*
* @return array<string, array>
*/
private function sections(array $document, array $areas): array
{
$resolved = [];
$pending = $this->partials->referencedSlugs($document);
foreach ($areas as $area) {
$pending = [...$pending, ...$this->partials->referencedSlugs($area)];
}
for ($depth = 0; $depth < PartialRepository::MAX_NESTING && [] !== $pending; ++$depth) {
$next = [];
foreach (array_unique($pending) as $slug) {
if (isset($resolved[$slug])) {
continue;
}
$section = $this->partials->resolveSection($slug);
if (null === $section) {
continue;
}
$resolved[$slug] = $section['document'];
$next = [...$next, ...$this->partials->referencedSlugs($section['document'])];
}
$pending = $next;
}
return $resolved;
}
/**
* @return array<string, list<array>>
*/
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Controller;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* Header, footer and reusable sections in the same editor as a page.
*
* The editor front end takes its API base from a data attribute, so it works against pages and
* against areas without a second implementation. Areas have no draft and no publication: they are
* shared, so a saved change is live. The editor hides the publish button for them.
*/
#[Route('/admin/partials')]
final class PartialController extends AbstractController
{
public function __construct(
private PartialRepository $partials,
private DocumentValidator $validator,
private MenuRepository $menus,
private Theme $theme,
private StyleCompiler $styles,
private CsrfTokenManagerInterface $csrf,
) {
}
#[Route('', name: 'admin_partials', methods: ['GET'])]
public function index(): Response
{
$partials = [];
foreach ($this->partials->all() as $partial) {
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
$partials[] = $partial;
}
return $this->render('admin/partials.html.twig', ['partials' => $partials, 'kinds' => PartialRepository::KINDS]);
}
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
public function create(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$name = trim($request->request->getString('name'));
try {
$id = $this->partials->create(
$request->request->getString('kind', 'section'),
$request->request->getString('slug') ?: $this->slugify($name),
$name,
// A shared area starts with one empty section, so there is something to click on.
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
]],
);
} catch (InvalidPage|InvalidDocument $e) {
return $this->back($e->getMessage());
}
return $this->redirectToRoute('admin_partial_editor', ['id' => $id]);
}
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->partials->delete($id);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Sektion wurde entfernt. Seiten, die sie einbinden, zeigen an dieser Stelle nichts mehr.', 'success');
}
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
public function editor(string $id): Response
{
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
}
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
public function document(string $id): JsonResponse
{
return $this->json($this->payload($this->partial($id)));
}
#[Route('/{id}/draft', name: 'admin_partial_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function write(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
if (strlen($request->getContent()) > 270000) {
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
}
$partial = $this->partial($id);
try {
$body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR);
$document = $body['document'] ?? null;
if (!is_array($body) || !is_array($document)) {
throw new \InvalidArgumentException();
}
if ('admin_partial_preview' === $request->attributes->get('_route')) {
$this->validator->validate($document, false);
return $this->renderPreview($partial, $document);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
throw new \InvalidArgumentException();
}
$this->partials->save($id, $document, $body['expectedVersion']);
$partial['document'] = $document;
$partial['version'] = $body['expectedVersion'] + 1;
return $this->json($this->payload($partial));
} catch (StaleWrite $e) {
return $this->json(['error' => $e->getMessage(), 'conflict' => true], 409);
} catch (InvalidDocument|InvalidPage $e) {
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
} catch (\JsonException|\InvalidArgumentException) {
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Dokument.'], 422);
}
}
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
private function renderPreview(array $partial, array $document): Response
{
$theme = $this->theme->load();
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
}
}
return $this->render('page.html.twig', [
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
'canonical' => '',
'editing' => true,
'navigation' => $navigation,
'areas' => [],
'sections' => $sections,
'postLists' => [],
'postMeta' => null,
'needsTabs' => false,
'forms' => [],
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
'googleFonts' => $this->theme->googleFontsUrl($theme),
]);
}
private function partial(string $id): array
{
return $this->partials->find($id) ?? throw $this->createNotFoundException('Bereich nicht gefunden.');
}
private function payload(array $partial): array
{
return [
'document' => $partial['document'],
'version' => $partial['version'],
'revisionId' => null,
'publishedRevisionId' => null,
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
];
}
private function slugify(string $name): string
{
$slug = strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
return substr(trim(preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '', '-'), 0, 80);
}
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_partials');
}
}
+9 -5
View File
@@ -1,12 +1,16 @@
{% extends 'admin/base.html.twig' %}
{% block title %}{{ page.document.title }} bearbeiten{% endblock %}
{% set area = partial|default(null) %}
{% set subject = area ? area.document : page.document %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js"></script>{% endblock %}
{% block body %}
<div id="editor" data-page-id="{{ page.id }}" data-can-publish="{{ is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
{# The API base decides what is edited. An area is shared, so it has no draft and no
publication: saving is live, and the publish button is left out. #}
<div id="editor" data-api="{{ area ? '/admin/partials/' ~ area.id : '/admin/api/pages/' ~ page.id }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<header class="editor-header">
<a class="back-link" href="{{ path('admin_pages') }}" aria-label="Zur Seitenübersicht">← <span>Seiten</span></a>
<div class="editor-page-name"><h1>{{ page.document.title }}</h1><span>/{{ page.slug == 'home' ? '' : page.slug }}</span></div>
<a class="back-link" href="{{ area ? path('admin_partials') : path('admin_pages') }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : 'Zur Seitenübersicht' }}">← <span>{{ area ? 'Bereiche' : 'Seiten' }}</span></a>
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>Entwurf speichern</button>{% if is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
</header>
@@ -17,7 +21,7 @@
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
<div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
</div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<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>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_partials') }}">Bereiche</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</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>
+1 -1
View File
@@ -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>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_partials') }}">Bereiche</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</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>
+81
View File
@@ -0,0 +1,81 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Bereiche{% 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">BEREICHE</p><h1>Kopf, Fuß und wiederverwendbare Sektionen</h1>
<p class="muted">Alle drei sind Blockdokumente wie eine Seite und im selben Editor frei gestaltbar. Eine <strong>verknüpfte</strong> Sektion wird nicht kopiert: eine Änderung erreicht jede Seite, die sie einbindet.</p>
<p class="admin-links">
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a>
<a class="secondary" href="{{ path('admin_theme') }}">Design</a>
<a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</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>
{% endfor %}{% endfor %}
{% if is_granted('ROLE_EDITOR') %}
<details class="create-panel"{% if app.flashes is not empty %} open{% endif %}>
<summary>Neuen Bereich anlegen</summary>
<form action="{{ path('admin_partial_create') }}" method="post" class="create-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('partials') }}">
<div class="create-grid">
<div>
<label for="new-name">Name</label>
<input id="new-name" name="name" maxlength="120" required autocomplete="off">
</div>
<div>
<label for="new-kind">Art</label>
<select id="new-kind" name="kind">
{% for value, label in kinds %}<option value="{{ value }}"{{ value == 'section' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
<p class="field-help">Kopf- und Fußbereich gibt es je einmal.</p>
</div>
<div>
<label for="new-slug">Adresse</label>
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off">
<p class="field-help">Wird im Verknüpfungsblock eingetragen. Leer lassen, um sie aus dem Namen zu bilden.</p>
</div>
</div>
<button class="primary" type="submit">Anlegen und bearbeiten</button>
</form>
</details>
{% endif %}
<div class="section-heading"><h2>Bereiche <span class="count">{{ partials|length }}</span></h2><span class="muted">Gespeicherte Änderungen sind sofort auf allen Seiten sichtbar.</span></div>
<div class="page-list">
{% for partial in partials %}
<article class="page-card">
<div class="page-icon" aria-hidden="true">{{ partial.kind == 'header' ? '▀' : (partial.kind == 'footer' ? '▄' : '▥') }}</div>
<div class="page-info">
<h3><a href="{{ path('admin_partial_editor', {id: partial.id}) }}">{{ partial.name }}</a></h3>
<p>{{ kinds[partial.kind] }}{% if partial.kind == 'section' %} · Adresse <code>{{ partial.slug }}</code>{% endif %}
{% if partial.kind == 'section' %}
· {% if partial.usedBy is empty %}<span>nirgends eingebunden</span>{% else %}eingebunden in {{ partial.usedBy|join(', ') }}{% endif %}
{% endif %}
</p>
</div>
<a class="secondary" href="{{ path('admin_partial_editor', {id: partial.id}) }}" aria-label="{{ partial.name }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
{% if partial.kind == 'section' and is_granted('ROLE_EDITOR') %}
<details class="delete-panel">
<summary aria-label="{{ partial.name }} entfernen">Entfernen</summary>
<form action="{{ path('admin_partial_delete', {id: partial.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('partials') }}">
<p>Entfernt „{{ partial.name }}“.{% if partial.usedBy is not empty %} <strong>Noch eingebunden in {{ partial.usedBy|join(', ') }}</strong> – dort erscheint danach nichts mehr.{% endif %}</p>
<button class="secondary danger-text" type="submit">Sektion entfernen</button>
</form>
</details>
{% endif %}
</article>
{% else %}
<div class="empty-state"><h3>Noch keine Bereiche.</h3><p>Ein frisches Setup legt Kopf- und Fußbereich automatisch an.</p></div>
{% endfor %}
</div>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
</main>
{% endblock %}
+1 -1
View File
@@ -10,7 +10,7 @@
<p class="muted">Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.</p>
<p class="admin-links">
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_partials') }}">Bereiche</a> <a class="secondary" href="{{ path('admin_theme') }}">Design</a>
<a class="secondary" href="{{ path('admin_forms') }}">Formulare</a>
<a class="secondary" href="/sitemap.xml">Sitemap ↗</a>
<a class="secondary" href="/robots.txt">robots.txt ↗</a>
+8 -11
View File
@@ -15,12 +15,11 @@
</head>
<body class="kant">
<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>
{% if navigation.header|default([]) is not empty %}
<nav aria-label="Hauptnavigation">{% include 'nav.html.twig' with {links: navigation.header} only %}</nav>
{% endif %}
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
<section class="block-section tone-mint width-normal">
<div class="shell section-inner">
@@ -42,12 +41,10 @@
</div>
</section>
</main>
<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>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</footer>
{% endif %}
</body>
</html>
+27 -7
View File
@@ -10,7 +10,7 @@
{% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %}
</{{ tag }}>
{% elseif type == 'heading' %}
@@ -39,7 +39,7 @@
</figure>
{% elseif type == 'logos' %}
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}</li>{% endfor %}
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}</li>{% endfor %}
</ul>
{% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -59,6 +59,26 @@
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
{% elseif type == 'spacer' %}
<div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div>
{% elseif type == 'menu' %}
{% set links = navigation[props.slot]|default([]) %}
{% if links is not empty %}
<nav id="{{ block.id }}" class="block-menu{{ designClasses }}" aria-label="{{ props.slot == 'footer' ? 'Fußzeile' : 'Hauptnavigation' }}">
{% include 'nav.html.twig' with {links: links} only %}
</nav>
{% endif %}
{% elseif type == 'partialref' %}
{# A linked section: its blocks come from the partial, resolved by the controller. One change
there reaches every page that links it, which is the difference to a copied section. #}
{% set section = sections[props.partial]|default(null) %}
{% if section %}
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</div>
{% elseif editing|default(false) %}
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
{% else %}
<!-- Verknüpfte Sektion {{ props.partial }} ist nicht vorhanden. -->
{% endif %}
{% elseif type == 'breadcrumb' %}
{% if breadcrumbs|default([])|length > 1 %}
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
@@ -81,22 +101,22 @@
<ul class="tabs-nav">
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
</ul>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</div>
{% elseif type == 'tabitem' %}
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
<h3 class="tabs-heading">{{ props.title }}</h3>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</section>
{% elseif type == 'accordion' %}
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</div>
{% elseif type == 'accordionitem' %}
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
<summary>{{ props.title }}</summary>
<div class="accordion-body">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</div>
</details>
{% elseif type == 'postlist' %}
@@ -105,7 +125,7 @@
</div>
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</div>
{% else %}
{#
+11 -12
View File
@@ -32,12 +32,13 @@
</head>
<body class="kant">
<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>
{% if navigation.header|default([]) is not empty %}
<nav aria-label="Hauptnavigation">{% include 'nav.html.twig' with {links: navigation.header} only %}</nav>
{% endif %}
{# Header and footer are block documents like any page, so they are built from elements
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
{% if postMeta is defined and postMeta is not null %}
<p class="post-header shell">
@@ -47,14 +48,12 @@
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
{% endif %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</main>
<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>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
</footer>
{% endif %}
</body>
</html>
+126
View File
@@ -7,6 +7,7 @@ use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
@@ -27,6 +28,7 @@ use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use DoctrineMigrations\Version20260912000900;
use DoctrineMigrations\Version20260912001000;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -42,6 +44,7 @@ final class CmsTest extends TestCase
private SiteSettings $site;
private RedirectRepository $redirects;
private Theme $themes;
private PartialRepository $partials;
private string $path;
protected function setUp(): void
@@ -57,6 +60,7 @@ final class CmsTest extends TestCase
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
(new Version20260912001000($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
@@ -73,6 +77,11 @@ final class CmsTest extends TestCase
$this->site = new SiteSettings($this->db);
$this->redirects = new RedirectRepository($this->db);
$this->themes = new Theme($this->db);
$this->partials = new PartialRepository($this->db, new DocumentValidator());
// A real installation has a header and a footer; the navigation lives in the header area.
foreach (DemoContent::areas() as $kind => $area) {
$this->partials->create($kind, 'default', $area['name'], $area['document']);
}
}
protected function tearDown(): void
@@ -1419,6 +1428,123 @@ final class CmsTest extends TestCase
self::assertSame($before, $this->site->load()['homePath']);
}
public function testHeaderAndFooterAreEditableDocumentsThatReachEveryPage(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$second = $this->publishedPage('zweite', 'Zweite Seite');
self::assertNotNull($this->pages->findForEditor($second));
// The seeded areas render as ordinary blocks, and the navigation block shows the menu.
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
], 0);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<header class="site-header">', $html);
self::assertStringContainsString('<p id="area-header-name" class="block-text">kant cms</p>', $html);
self::assertStringContainsString('aria-label="Hauptnavigation"', $html);
self::assertStringContainsString('>Start</a>', $html);
// Changing the header reaches every page at once, because it is one shared document.
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = 'Neue Wortmarke';
$this->partials->save($header['id'], $document, $header['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Neue Wortmarke', $this->request($path)->getContent(), $path);
self::assertStringNotContainsString('kant cms', $this->request($path)->getContent(), $path);
}
// An emptied area renders nothing rather than leaving a broken shell behind.
$footer = $this->partials->findByKind('footer');
$this->partials->save($footer['id'], [...$footer['document'], 'blocks' => []], $footer['version']);
self::assertStringContainsString('<footer class="site-footer">', $this->request('/')->getContent());
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
}
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
{
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
'schemaVersion' => 11, 'title' => 'Aufruf-Band', 'description' => 'Wiederverwendbar.',
'blocks' => [['id' => 'cta-section', 'type' => 'section', 'children' => [
['id' => 'cta-heading', 'type' => 'heading', 'props' => ['text' => 'Bereit?', 'level' => 2]],
]]],
]);
$withRef = static function (string $title): array {
$document = DemoContent::starter($title);
$document['schemaVersion'] = 11;
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'aufruf']];
return $document;
};
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $withRef('Erste'), 0), 1);
$other = $this->pages->createPage('zweite', null, 'page', $withRef('Zweite'));
$page = $this->pages->findForEditor($other);
$this->pages->publish($other, $page['draft_revision_id'], $page['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Bereit?', $this->request($path)->getContent(), $path);
}
// The page documents carry only the address, never the section's blocks.
self::assertSame(['partial' => 'aufruf'], $this->pages->findPublished('zweite')['document']['blocks'][2]['props']);
// One change in the section reaches both pages.
$section = $this->partials->find($sectionId);
$document = $section['document'];
$document['blocks'][0]['children'][0]['props']['text'] = 'Jetzt anfragen';
$this->partials->save($sectionId, $document, $section['version']);
foreach (['/', '/zweite'] as $path) {
self::assertStringContainsString('Jetzt anfragen', $this->request($path)->getContent(), $path);
self::assertStringNotContainsString('Bereit?', $this->request($path)->getContent(), $path);
}
self::assertSame(['/home', '/zweite'], $this->partials->usedBy('aufruf'));
// Removing the section leaves both pages intact; the reference stays stored.
$this->partials->delete($sectionId);
$stripped = $this->request('/');
self::assertSame(200, $stripped->getStatusCode());
self::assertStringContainsString('<!-- Verknüpfte Sektion aufruf ist nicht vorhanden. -->', $stripped->getContent());
self::assertSame('aufruf', $this->pages->findPublished('home')['document']['blocks'][2]['props']['partial']);
}
public function testSharedAreasRefuseAMainHeadingAndSectionsRefuseToContainThemselves(): void
{
// A page brings its own main heading; an area appearing on every page must not add one.
try {
$this->partials->create('section', 'mit-h1', 'Mit H1', [
'schemaVersion' => 11, 'title' => 'Mit H1', 'description' => 'x',
'blocks' => [['id' => 'h', 'type' => 'heading', 'props' => ['text' => 'Titel', 'level' => 1]]],
]);
self::fail('Expected a main heading in a shared area to be refused.');
} catch (InvalidDocument) {
}
$id = $this->partials->create('section', 'selbst', 'Selbstbezug', [
'schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x',
'blocks' => [['id' => 's', 'type' => 'section', 'children' => []]],
]);
$cyclic = ['schemaVersion' => 11, 'title' => 'Selbstbezug', 'description' => 'x', 'blocks' => [
['id' => 's', 'type' => 'partialref', 'props' => ['partial' => 'selbst']],
]];
try {
$this->partials->save($id, $cyclic, $this->partials->find($id)['version']);
self::fail('Expected a section containing itself to be refused.');
} catch (InvalidPage) {
}
// Nothing was written, so the section still holds its original block.
self::assertSame('section', $this->partials->find($id)['document']['blocks'][0]['type']);
// Only one header and one footer.
try {
$this->partials->create('header', 'zweiter', 'Zweiter Kopf', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
self::fail('Expected a second header to be refused.');
} catch (InvalidPage) {
}
// And they cannot be deleted, only emptied.
$this->expectException(InvalidPage::class);
$this->partials->delete($this->partials->findByKind('header')['id']);
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];
+8
View File
@@ -7,6 +7,7 @@ use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
@@ -22,6 +23,7 @@ use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use DoctrineMigrations\Version20260912000900;
use DoctrineMigrations\Version20260912001000;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
@@ -58,6 +60,7 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
(new Version20260912001000($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
@@ -69,6 +72,11 @@ final class EditorSecurityTest extends WebTestCase
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
$pages->publish('p1', $revision, 1);
// The navigation is part of the header area, so the fixture needs one.
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
}
+7
View File
@@ -6,6 +6,7 @@ require dirname(__DIR__).'/vendor/autoload.php';
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory;
use Doctrine\DBAL\Schema\Schema;
@@ -18,6 +19,7 @@ use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use DoctrineMigrations\Version20260912000900;
use DoctrineMigrations\Version20260912001000;
use Psr\Log\NullLogger;
$directory = dirname(__DIR__).'/var/browser-tests';
@@ -37,6 +39,7 @@ $schema = new Schema();
(new Version20260912000700($db, new NullLogger()))->up($schema);
(new Version20260912000800($db, new NullLogger()))->up($schema);
(new Version20260912000900($db, new NullLogger()))->up($schema);
(new Version20260912001000($db, new NullLogger()))->up($schema);
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
$db->executeStatement($sql);
}
@@ -51,6 +54,10 @@ foreach (DemoContent::pages() as $slug => $document) {
$revision = $pages->saveDraft($id, $document, 0);
$pages->publish($id, $revision, 1);
}
$partials = new PartialRepository($db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
$password = bin2hex(random_bytes(20));
$username = 'browser-'.bin2hex(random_bytes(6));
$db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => '["ROLE_ADMIN"]']);