diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e9af609..d018f61 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 04377cb..8a58395 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -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).* diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 3df37eb..2c1712e 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -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**. diff --git a/docs/STATUS.md b/docs/STATUS.md index c898c3f..d223f34 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/migrations/Version20260912001000.php b/migrations/Version20260912001000.php new file mode 100644 index 0000000..75ca928 --- /dev/null +++ b/migrations/Version20260912001000.php @@ -0,0 +1,40 @@ +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.'); + } +} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index a998941..cee6496 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -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; diff --git a/public/assets/editor.js b/public/assets/editor.js index f7e0638..8382f01 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -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.'); diff --git a/public/assets/site.css b/public/assets/site.css index 783b3c1..af6f0cd 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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} diff --git a/src/Command/SeedDemoCommand.php b/src/Command/SeedDemoCommand.php index 08bac46..ae630ce 100644 --- a/src/Command/SeedDemoCommand.php +++ b/src/Command/SeedDemoCommand.php @@ -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); + } + } } diff --git a/src/Content/DemoContent.php b/src/Content/DemoContent.php index 5dc79eb..dd3d4dc 100644 --- a/src/Content/DemoContent.php +++ b/src/Content/DemoContent.php @@ -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 + */ + 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')]; diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index f9aa589..5e119db 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -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. diff --git a/src/Content/PartialRepository.php b/src/Content/PartialRepository.php new file mode 100644 index 0000000..94c3ee5 --- /dev/null +++ b/src/Content/PartialRepository.php @@ -0,0 +1,202 @@ + '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 */ + 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 */ + 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 + */ + 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; + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index baacfea..99ed583 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -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(); diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 520ce45..c4132b3 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -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 + */ + 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 + */ + 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> */ diff --git a/src/Controller/PartialController.php b/src/Controller/PartialController.php new file mode 100644 index 0000000..bd474f5 --- /dev/null +++ b/src/Controller/PartialController.php @@ -0,0 +1,206 @@ +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'); + } +} diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 2c3c505..827629d 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -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 %}{% endblock %} {% block body %} -
+{# 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. #} +
- ← Seiten -

{{ page.document.title }}

/{{ page.slug == 'home' ? '' : page.slug }}
+ ← {{ area ? 'Bereiche' : 'Seiten' }} +

{{ subject.title }}

{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}
Wird geladen …{% if is_granted('ROLE_PUBLISHER') %}{% endif %}
@@ -17,7 +21,7 @@

Werkzeugkasten

Element hinzufügen

In die gewählte Sektion oder hinter das gewählte Element.

- {% 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'} %}{% 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'} %}{% endfor %}

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index 0af19dd..38af060 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -8,7 +8,7 @@

NAVIGATION

Menüs

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.

- + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 55ab80c..ff67724 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -8,7 +8,7 @@

DEINE WEBSITE

Gute Inhalte beginnen hier.

Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.

- + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

diff --git a/templates/admin/partials.html.twig b/templates/admin/partials.html.twig new file mode 100644 index 0000000..4a918a4 --- /dev/null +++ b/templates/admin/partials.html.twig @@ -0,0 +1,81 @@ +{% extends 'admin/base.html.twig' %} +{% block title %}Bereiche{% endblock %} +{% block body %} +
+ kantcms +
Website ansehen ↗{{ app.user.userIdentifier }}
+
+
+

BEREICHE

Kopf, Fuß und wiederverwendbare Sektionen

+

Alle drei sind Blockdokumente wie eine Seite und im selben Editor frei gestaltbar. Eine verknüpfte Sektion wird nicht kopiert: eine Änderung erreicht jede Seite, die sie einbindet.

+ + + {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %} +

{{ message }}

+ {% endfor %}{% endfor %} + + {% if is_granted('ROLE_EDITOR') %} +
+ Neuen Bereich anlegen +
+ +
+
+ + +
+
+ + +

Kopf- und Fußbereich gibt es je einmal.

+
+
+ + +

Wird im Verknüpfungsblock eingetragen. Leer lassen, um sie aus dem Namen zu bilden.

+
+
+ +
+
+ {% endif %} + +

Bereiche {{ partials|length }}

Gespeicherte Änderungen sind sofort auf allen Seiten sichtbar.
+
+ {% for partial in partials %} +
+ +
+

{{ partial.name }}

+

{{ kinds[partial.kind] }}{% if partial.kind == 'section' %} · Adresse {{ partial.slug }}{% endif %} + {% if partial.kind == 'section' %} + · {% if partial.usedBy is empty %}nirgends eingebunden{% else %}eingebunden in {{ partial.usedBy|join(', ') }}{% endif %} + {% endif %} +

+
+ Bearbeiten + {% if partial.kind == 'section' and is_granted('ROLE_EDITOR') %} +
+ Entfernen +
+ +

Entfernt „{{ partial.name }}“.{% if partial.usedBy is not empty %} Noch eingebunden in {{ partial.usedBy|join(', ') }} – dort erscheint danach nichts mehr.{% endif %}

+ +
+
+ {% endif %} +
+ {% else %} +

Noch keine Bereiche.

Ein frisches Setup legt Kopf- und Fußbereich automatisch an.

+ {% endfor %} +
+

Kant CMS · Visueller Editor, erster Prototyp

+
+{% endblock %} diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 4dc1baf..d86c50f 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -10,7 +10,7 @@

Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.

{% elseif type == 'gallery' %}
- {% 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 %}
{% else %} {# diff --git a/templates/page.html.twig b/templates/page.html.twig index fddee4b..ef94d29 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -32,12 +32,13 @@ -