From ebd5883593d1fb001e3503b900bb6218523ee616 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sat, 12 Sep 2026 11:25:09 +0200 Subject: [PATCH] =?UTF-8?q?Beitr=C3=A4ge=20mit=20Archiven,=20Beitragsliste?= =?UTF-8?q?=20und=20Feed=20(Arbeitspaket=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 20260912000600 ergänzt page.excerpt, author, featured_media_id und published_at sowie die Tabellen term und page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das Archiv sortiert und paginiert damit in SQL, statt für jede Auflistung alle Dokumente zu dekodieren. Kategorien und Schlagwörter teilen sich eine Tabelle mit einem kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen, die Begriffe bleiben als wiederverwendbare Etiketten; ein leer gewordenes Archiv antwortet weiter mit 200 und leerer Liste. Der Block postlist (Dokumentschema v5) speichert nur Anzahl und Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen, damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag zeigen kann. Archive unter /beitraege, /kategorie/{slug} und /schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind für Wurzelseiten reserviert. Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22, 12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 14 ++ docs/AUSBAUPLAN.md | 4 +- docs/QUALITY.md | 12 ++ docs/STATUS.md | 10 +- migrations/Version20260912000600.php | 77 +++++++++++ public/assets/admin.css | 3 + public/assets/editor-model.js | 10 +- public/assets/editor.js | 5 + public/assets/site.css | 15 +++ src/Content/DocumentValidator.php | 14 +- src/Content/PageRepository.php | 10 +- src/Content/PostRepository.php | 186 +++++++++++++++++++++++++++ src/Controller/EditorController.php | 38 +++++- src/Controller/PageController.php | 83 +++++++++++- templates/admin/editor.html.twig | 2 +- templates/admin/pages.html.twig | 26 +++- templates/archive.html.twig | 53 ++++++++ templates/blocks/block.html.twig | 8 +- templates/blocks/post-list.html.twig | 30 +++++ templates/feed.xml.twig | 20 +++ templates/page.html.twig | 11 +- tests/CmsTest.php | 172 ++++++++++++++++++++++++- tests/EditorSecurityTest.php | 3 + tests/browser-fixture.php | 3 + tests/editor-model.test.mjs | 20 +++ 25 files changed, 808 insertions(+), 21 deletions(-) create mode 100644 migrations/Version20260912000600.php create mode 100644 src/Content/PostRepository.php create mode 100644 templates/archive.html.twig create mode 100644 templates/blocks/post-list.html.twig create mode 100644 templates/feed.xml.twig diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c501990..d463cf5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,3 +148,17 @@ Die Verwaltung arbeitet mit einer flachen, nummerierten Zeilenliste statt mit Dr Das Testkernel läuft mit `debug = false`. Symfony prüft dann weder den kompilierten Container noch die kompilierten Twig-Vorlagen auf Aktualität. Zweimal hat das während der Entwicklung ein falsches Ergebnis erzeugt: eine geänderte Vorlage wurde aus dem Cache beantwortet und ein Test bestand fälschlich, später schlug ein geänderter Konstruktor mit einem `TypeError` aus einem veralteten Container fehl. `tests/bootstrap.php` löscht deshalb `var/cache/test` einmal pro Lauf. Das kostet rund eine Sekunde und schließt diese Fehlerklasse aus. Zusätzlich lädt Twig in der Testumgebung Vorlagen automatisch neu, und das Login-Throttling zählt dort in einem Array-Cache statt in einem dauerhaften Pool – beide Einstellungen betreffen ausschließlich die Testumgebung, die geprüfte Drosselung selbst bleibt aktiv. + +## ADR 014 – Beiträge: Metadaten als Seitenspalten, Beitragsliste als Renderer-Block (12.09.2026) + +Beitragsdatum, Autor, Auszug und Beitragsbild beschreiben die Seite, nicht ihren Inhalt. Sie liegen deshalb als Spalten auf `page` und nicht im Blockdokument. Das hält Dokumentschema und Editor unverändert, und vor allem kann das Archiv in SQL sortieren und paginieren, statt für jede Auflistung sämtliche Dokumente zu dekodieren. + +`published_at` wird beim Veröffentlichen gesetzt, falls noch keins gepflegt ist. Es ist damit kein zweiter Wahrheitsort, sondern die eine gepflegte Angabe; die Sortierung stützt sich auf den Index `idx_page_published_at`. Seiten werden nie gestempelt – nur Beiträge. + +Kategorien und Schlagwörter teilen sich die Tabelle `term` mit einem Feld `kind`, weil sie sich bis auf ihr Archiv identisch verhalten. Namen werden beim Speichern auf ihren Slug abgebildet und bei Bedarf angelegt; die Zuordnung in `page_term` wird bei jedem Speichern ersetzt statt ergänzt. Beim Löschen einer Seite verschwinden ihre Zuordnungen, die Begriffe selbst bleiben: sie sind wiederverwendbare Etiketten, kein Inhalt eines einzelnen Beitrags. Ein leer gewordenes Archiv antwortet deshalb mit HTTP 200 und einer leeren Liste, nicht mit 404. + +**Der Block `postlist` (Dokumentschema v5) speichert keine Beiträge, sondern nur Anzahl und Filter.** Die Beiträge stellt der Controller beim Ausliefern zusammen und übergibt sie getrennt an die Vorlage. Ein gespeichertes Dokument kann damit keinen Beitrag beanspruchen, den es nicht zeigen darf – insbesondere keinen unveröffentlichten. Dasselbe gilt in der Editorvorschau. + +Archive liegen unter `/beitraege`, `/kategorie/{slug}` und `/schlagwort/{slug}`, der Feed unter `/feed.xml`; alle vier Segmente sind für Wurzelseiten reserviert und aus dem öffentlichen Auffangmuster ausgenommen. Paginierung über `?seite=N`, mit `rel="prev"`/`rel="next"` und Begrenzung auf den gültigen Bereich statt einer leeren Seite bei zu großen Zahlen. + +Die Beitragsdaten werden über ein Formular in der Seitenübersicht gepflegt, nicht im Editor: es sind Seitendaten, kein Blockinhalt, und so bleibt die Bedienung ohne JavaScript möglich. Das Formular nutzt dieselbe optimistische Konflikterkennung wie jeder andere Schreibzugriff auf eine Seite. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index af8a281..70f89d2 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -64,10 +64,12 @@ Menüverwaltung mit benannten Menüs und sortierbaren Einträgen. Ein Eintrag ve Abnahme: Die feste Navigation im Template ist ersetzt. Ein Menü mit Anker-, Seiten- und externem Ziel wird korrekt ausgegeben. Eine gelöschte Seite macht das Menü nicht kaputt. -### B – Beiträge +### B – Beiträge — **erledigt am 12.09.2026** *Braucht: A für die Verlinkung.* +Umgesetzt: Datum, Autor, Auszug und Beitragsbild, Übersicht mit Paginierung, Beitragsliste als Block, Einzelansicht mit Datum, Autor und Begriffen, Kategorien und Schlagwörter mit Archiven, RSS. **Offen geblieben:** keine Bildvorschau in der Verwaltung (das Beitragsbild wird über seine Mediathek-ID eingetragen), keine Begriffsverwaltung zum Umbenennen oder Zusammenführen, feste zehn Beiträge je Archivseite. Details in ADR 014. + Beitragsdatum, Autor, Auszug und Beitragsbild im Datenmodell. Übersichtsseite mit Sortierung nach Datum und Paginierung. Ein Block „Beitragsliste“ zum Einbetten in beliebige Seiten, mit Anzahl und Filter. Einzelansicht mit Datum und Autor. Kategorien und Schlagwörter einschließlich ihrer Archivseiten. RSS-Feed. Abnahme: Die vier bestehenden Projektbeiträge des Piloten lassen sich als Beiträge abbilden, erscheinen in der Übersicht und in einer eingebetteten Liste auf der Startseite. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 86e5935..5b91930 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,17 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Beiträge, 12.09.2026 + +- **91 PHP-Tests / 347 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **12 JavaScript-Modelltests**. +- Archiv geprüft: Sortierung nach Datum über mehrere Seiten, Begrenzung zu großer und negativer Seitenzahlen auf den gültigen Bereich statt einer leeren Ausgabe, gewöhnliche Seiten und unveröffentlichte Beiträge erscheinen nicht, Canonical und Datumsausgabe stimmen. +- Kategorien und Schlagwörter geprüft: eigene Archive, getrennte Namensräume (ein Schlagwort-Slug ist keine Kategorie), unbekannter Begriff ergibt HTTP 404, erneutes Speichern ersetzt die Zuordnung statt sie zu ergänzen, und ein dadurch leer gewordenes Archiv antwortet weiter mit HTTP 200 und leerer Liste. Beim Löschen einer Seite verschwinden ihre Zuordnungen, die Begriffe selbst bleiben erhalten. +- **Der Block `postlist` wurde gezielt darauf geprüft, dass er keine Beitragsdaten speichert.** Das gespeicherte Dokument enthält nach dem Veröffentlichen ausschließlich Anzahl und Filter; die angezeigten Beiträge stellt der Controller zusammen. Ein Beitrag aus einer anderen Kategorie erscheint nicht. Damit kann ein gespeichertes Dokument keinen unveröffentlichten Beitrag zeigen. +- Feed geprüft: wohlgeformtes XML (`simplexml_load_string`), absolute Adressen, Kategorien als `category`-Elemente, kein unveröffentlichter Beitrag. +- Sechs abgewiesene Metadateneingaben (zu langer Auszug, zu langer Autorname, falsches Datumsformat, unbekanntes und fehlerhaftes Beitragsbild, unbrauchbarer Begriffsname) schreiben nichts und erhöhen die Seitenversion nicht. +- Veröffentlichen stempelt ein fehlendes Beitragsdatum, lässt ein gepflegtes unangetastet und rührt gewöhnliche Seiten nicht an. +- Migration `20260912000600` zuerst gegen eine Kopie der lokalen Datenbank ausgeführt und kontrolliert, danach nach Backup (`var/backups/before-posts-*.db`) angewendet. `composer lint`, `lint:twig`, `lint:yaml`, `lint:container` und wiederholtes Seeding sind erfolgreich. +- **Auch für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Archivseiten, Beitragsliste, Paginierung und das Formular für Beitragsdaten sind nicht visuell, nicht auf Barrierefreiheit und nicht auf Leistung geprüft. Insbesondere liegt für die Archivseiten keine Messung vor. + ## Ergänzung: Menüs, 12.09.2026 - **79 PHP-Tests / 285 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **11 JavaScript-Modelltests**. diff --git a/docs/STATUS.md b/docs/STATUS.md index ac153b8..c3b7914 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: 12.09.2026. Arbeitstitel Kant CMS. **Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. +Stand: 12.09.2026. Arbeitstitel Kant CMS. **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,7 @@ 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. +- Beiträge: Datum, Autor, Auszug und Beitragsbild als Seitenspalten, gepflegt über ein Formular in der Seitenübersicht. Kategorien und Schlagwörter als Begriffe mit eigenen Archiven unter `/kategorie/{slug}` und `/schlagwort/{slug}`, Gesamtübersicht unter `/beitraege` mit Paginierung über `?seite=N`, RSS unter `/feed.xml`. Der Block `postlist` (Dokumentschema v5) speichert nur Anzahl und Filter; die Beiträge stellt der Controller beim Ausliefern zusammen, damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag zeigen kann. - Menüverwaltung unter `/admin/menus` für die Plätze Hauptnavigation und Fußzeile. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, `mailto:`, `tel:`). Zwei Ebenen, bis 60 Einträge, optimistische Konflikterkennung. Bedienung über eine nummerierte Zeilenliste, vollständig ohne JavaScript; Untermenüs in der Ausgabe über `details`/`summary`. Seitenverweise speichern die Seiten-ID, nicht den Pfad – ein Eintrag folgt einer umbenannten Seite und überlebt eine gelöschte. - Dokumentformat v4: Blocktypen mit Namensraum (`acme.slider`) gelten als Bausteine einer Erweiterung und werden erhalten statt abgewiesen. Öffentlich rendert der Kern aus ihnen nichts außer einem HTML-Kommentar, in der Editorvorschau einen Platzhalter. Im Editor bleiben sie auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht bearbeitbar. v1 bis v3 bleiben unverändert unterstützt. - Login-Throttling nutzt in der Testumgebung einen Array-Cache. Zuvor überdauerten die Zähler den Testlauf und sperrten die Anmeldung nach wenigen Wiederholungen aus, was als Testfehler erschien. @@ -32,11 +33,13 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. ## Geprüft -- 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 11 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. +- 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 12 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. - Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator. +- Beiträge geprüft: Sortierung nach Datum mit Paginierung und Begrenzung zu großer Seitenzahlen, Seiten und unveröffentlichte Beiträge bleiben aus dem Archiv, Kategorie- und Schlagwortarchive einschließlich getrennter Namensräume, erneutes Speichern ersetzt die Zuordnung statt sie zu ergänzen, ein leer gewordenes Archiv antwortet weiter mit HTTP 200 und leerer Liste, Löschen löst die Zuordnungen und lässt die Begriffe bestehen, Veröffentlichen stempelt ein fehlendes Datum und lässt ein gepflegtes unangetastet, Seiten werden nie gestempelt. Sechs abgewiesene Metadateneingaben schreiben nichts und erhöhen die Version nicht. Die Beitragsliste zeigt nur Beiträge des gefilterten Begriffs, und das gespeicherte Dokument enthält weiterhin ausschließlich Anzahl und Filter. Der Feed ist wohlgeformtes XML mit absoluten Adressen und enthält keinen unveröffentlichten Beitrag. - Menüs geprüft: Seiten-, Anker- und externe Ziele einschließlich `tel:` in der Ausgabe, `aria-current` nur auf der tatsächlich aktuellen Seite und nicht auf Ankereinträgen, gelöschte und unveröffentlichte Seiten fallen aus der Navigation ohne die übrigen Einträge zu verlieren, Kinder eines übersprungenen Eintrags rücken nach, gespeicherte Einträge bleiben erhalten und erscheinen nach dem Veröffentlichen wieder. Zehn abgewiesene Eingaben (leere und überlange Beschriftung, unbekannte Zielart, fehlende Seite, ungültige Sprungziele, `javascript:`, `http:`, protokollrelative Adresse, dritte Ebene) schreiben nichts. Über HTTP zusätzlich: anonymer Schreibversuch, CSRF, veraltete Version, Aufbau des Baums aus flachen Formularzeilen mit Position und Ebene, normalisiertes Sprungziel und die Bestätigung, dass die Navigation nicht mehr aus dem Template stammt. - Plugin-Erhalt geprüft: ein v4-Dokument mit `acme.slider` samt verschachteltem Text wird Byte für Byte gespeichert, veröffentlicht und wieder geladen; weder Props noch Kindinhalte noch eine `javascript:`-URL erscheinen in der öffentlichen Ausgabe, die übrige Seite rendert normal. Fehlerhafte Namensräume und Plugin-Blöcke in v1 bis v3 werden weiterhin abgewiesen, ohne zu schreiben. Im JavaScript-Modell zusätzlich: Verschieben, Duplizieren mit neuen IDs, unverändertes Undo, kein Einfügen in den Baustein und keine Erzeugung über die Elementbibliothek. - Testumgebung: `tests/bootstrap.php` verwirft `var/cache/test` einmal pro Lauf. Ohne Debug prüft Symfony weder den kompilierten Container noch die Vorlagen auf Aktualität; das hat zweimal ein falsches Ergebnis erzeugt (eine Vorlagenänderung wurde übergangen, später schlug ein geänderter Konstruktor aus einem veralteten Container fehl). Zusätzlich lädt Twig in der Testumgebung automatisch neu. +- Migration `20260912000600` zuerst gegen eine Kopie der lokalen Datenbank ausgeführt und das Ergebnis kontrolliert: acht Anweisungen, vorhandene Seiten und Dokumente unverändert, neue Tabellen `term` und `page_term`, Sortierindex angelegt. Danach nach Backup unter `var/backups/before-posts-*.db` lokal angewendet. - Migration `20260912000500` zuerst gegen eine Kopie der lokalen Datenbank ausgeführt, dann nach Backup unter `var/backups/before-menus-*.db` lokal angewendet. Seeding legt fehlende Menüs an und lässt vorhandene unverändert; zweimal hintereinander ausgeführt. - Migration `20260912000400` zuerst gegen eine Kopie der lokalen Datenbank geprüft und die erzeugten Anweisungen kontrolliert: vier reine `ALTER TABLE … ADD COLUMN`, kein Tabellenneuaufbau. Danach nach konsistentem Backup unter `var/backups/before-hierarchy-20260912-102402.db` lokal angewendet; Backfill, Indexumstellung und unveränderte Dokumente überprüft. Wiederholtes Seeding und Produktionsbuild erfolgreich. - Composer-/Format-/Twig-/YAML-/Containerprüfungen, Migration, wiederholtes Seeding und Produktionsbuild erfolgreich. @@ -55,6 +58,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung. - Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen. - Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar. +- Beiträge haben noch keine Vorschau des Beitragsbilds in der Verwaltung: das Bild wird über seine Mediathek-ID eingetragen. Es gibt keine Begriffsverwaltung (Umbenennen, Zusammenführen, Löschen ungenutzter Begriffe), keine Beitragssuche und keine Archivbeschreibungen. Die Paginierung der Übersicht ist auf zehn Beiträge je Seite festgelegt. - Menüs kennen nur die beiden festen Plätze Hauptnavigation und Fußzeile; weitere Plätze, eigene Menüs pro Seite und automatisch aus der Hierarchie erzeugte Einträge gibt es nicht. Die Einträge werden über Position und Ebene sortiert, nicht per Drag-and-drop. - Seiten lassen sich anlegen und löschen, aber noch nicht umbenennen oder verschieben: eine Adressänderung müsste die Pfade aller Unterseiten mitziehen und Weiterleitungen anlegen. Beiträge werden gespeichert und ausgegeben, haben aber noch keine Übersichtsseite, keine Sortierung nach Datum und keine strukturierten Daten. Die Hauptnavigation ist weiterhin fest im Template; verschachtelte Seiten erscheinen dort nicht automatisch. - Für diesen Schritt wurden bewusst keine Browser-, Lighthouse- oder axe-Läufe ausgeführt (Nutzerentscheidung). Oberfläche und Ausgabe der neuen Formulare sind nur über die Funktionstests geprüft, nicht visuell. @@ -66,7 +70,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. ## Nächster konkreter Schritt -Arbeitspaket B aus `docs/AUSBAUPLAN.md`: Beiträge mit Datum, Autor, Auszug und Beitragsbild, Übersichtsseite mit Paginierung, einbettbare Beitragsliste, Kategorien und Schlagwörter samt Archiven sowie RSS. Danach Paket C mit den Blöcken, die die Startseite des Piloten braucht. +Arbeitspaket C aus `docs/AUSBAUPLAN.md`: die Blöcke, die die Startseite des Piloten braucht – Symbol-und-Text-Karte, Kennzahl, Kundenstimme, Logoleiste, Handlungsaufruf-Band, Kontaktdatenblock, Trennlinie, Abstandhalter, Akkordeon und Tabs. Danach Paket D mit dem Formularsystem. `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/Version20260912000600.php b/migrations/Version20260912000600.php new file mode 100644 index 0000000..11d369e --- /dev/null +++ b/migrations/Version20260912000600.php @@ -0,0 +1,77 @@ +getTable('page'); + $page->addColumn('excerpt', 'string', ['length' => 500, 'notnull' => false]); + $page->addColumn('author', 'string', ['length' => 120, 'notnull' => false]); + $page->addColumn('featured_media_id', 'string', ['length' => 32, 'notnull' => false]); + // Written when a post is published, read for ordering and pagination. The editable value + // is the draft metadata; this column is what the public archive sorts by. + $page->addColumn('published_at', 'datetime_immutable', ['notnull' => false]); + + $term = $schema->createTable('term'); + $term->addColumn('id', 'string', ['length' => 64]); + $term->addColumn('website_id', 'string', ['length' => 64]); + $term->addColumn('locale', 'string', ['length' => 12]); + // 'category' or 'tag'. One table because both behave identically apart from their archive. + $term->addColumn('kind', 'string', ['length' => 16]); + $term->addColumn('slug', 'string', ['length' => 80]); + $term->addColumn('name', 'string', ['length' => 120]); + $term->setPrimaryKey(['id']); + $term->addUniqueIndex(['website_id', 'locale', 'kind', 'slug'], 'uniq_term_slug'); + + $assignment = $schema->createTable('page_term'); + $assignment->addColumn('page_id', 'string', ['length' => 64]); + $assignment->addColumn('term_id', 'string', ['length' => 64]); + $assignment->setPrimaryKey(['page_id', 'term_id']); + $assignment->addIndex(['term_id'], 'idx_page_term_term'); + } + + public function postUp(Schema $schema): void + { + self::applyIndexes($this->connection); + } + + /** Shared with the test bootstraps so they get the same ordering index as a migrated database. */ + public static function applyIndexes(Connection $connection): void + { + $manager = $connection->createSchemaManager(); + if (isset($manager->listTableIndexes('page')['idx_page_published_at'])) { + return; + } + $connection->executeStatement($connection->getDatabasePlatform()->getCreateIndexSQL( + new Index('idx_page_published_at', ['website_id', 'locale', 'type', 'published_at']), + 'page', + )); + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException('Contains post metadata and term assignments. Restore a verified database backup with matching code.'); + } +} diff --git a/public/assets/admin.css b/public/assets/admin.css index b10ab2d..0d8e961 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -15,3 +15,6 @@ @media (max-width:720px){.page-card{flex-wrap:wrap}.pages-main{margin-block:40px}.delete-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}} .admin-links{display:flex;flex-wrap:wrap;gap:10px;margin:0}.menu-section{margin-top:46px}.menu-form{background:white;border:1px solid var(--line);border-radius:10px;padding:8px 24px 26px}.menu-form>label:first-of-type{margin-top:18px}.menu-form>input[name=name]{max-width:420px}.menu-table{width:100%;border-collapse:collapse;margin-top:24px;display:block;overflow-x:auto}.menu-table th{text-align:left;font-size:11px;letter-spacing:.4px;color:var(--muted);padding:0 8px 8px;font-weight:700;white-space:nowrap}.menu-table td{padding:4px 8px 4px 0;vertical-align:top}.menu-table input,.menu-table select{font-size:12px;min-height:38px;padding:8px 10px}.menu-table td:first-child input{width:78px}.menu-table td:nth-child(2) select{width:140px}.menu-table td:nth-child(3) input{min-width:170px}.menu-table td:nth-child(4) select{width:104px}.menu-table td:nth-child(5) select{min-width:190px}.menu-table td:nth-child(6) input{width:130px}.menu-table td:nth-child(7) input{min-width:190px}.menu-row-new input,.menu-row-new select{background:#fafbf8}.menu-form .primary{margin-top:20px}.menu-form code{background:#f0f4e9;padding:1px 5px;border-radius:4px} + +.meta-panel>summary{font-size:12px;font-weight:600;cursor:pointer;padding:11px 13px;border:1px solid var(--line);border-radius:7px;list-style:none}.meta-panel[open]{position:relative}.meta-panel[open]>form{position:absolute;right:0;top:calc(100% + 8px);z-index:3;width:330px;background:white;border:1px solid var(--line);border-radius:10px;padding:6px 20px 22px;box-shadow:0 14px 40px #203b3422}.meta-panel form label{margin-top:16px}.meta-panel .primary{margin-top:20px;width:100%}.meta-panel textarea{min-height:74px;font-size:12px}.meta-panel input{font-size:12px} +@media (max-width:720px){.meta-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index db7d7d3..9cb3fb5 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' }; -export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦' }; +export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste' }; +export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷' }; // 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}$/; @@ -28,6 +28,7 @@ export function createBlock(type) { if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 }; if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' }; if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' }; + if (type === 'postlist') block.props = { count: 3, category: '', tag: '' }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } @@ -35,6 +36,7 @@ export function createBlock(type) { export function insertBlock(document, type, selected) { const block = createBlock(type); if (type === 'gallery') upgradeDocument(document, 3); + if (type === 'postlist') upgradeDocument(document, 5); 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); @@ -76,7 +78,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].includes(document.schemaVersion) || ![2, 3, 4].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5].includes(document.schemaVersion) || ![2, 3, 4, 5].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } @@ -93,7 +95,7 @@ export function effectiveDesign(block, device, key) { } export function setDesign(document, id, device, key, value) { - if (![1, 2, 3, 4].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5].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 9c37cfe..4d0f708 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -170,6 +170,11 @@ function renderProperties() { } if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' }); if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' }); + if (block.type === 'postlist') { + field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' }); + field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' }); + field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' }); + } if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); if (block.type === 'image') { const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; diff --git a/public/assets/site.css b/public/assets/site.css index 49a756a..5f80cca 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -15,3 +15,18 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto} .menu-branch[open]>summary::after{content:' ▴'} .menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px} .menu-level-2{flex-direction:column;align-items:flex-start;gap:2px} + +/* Posts */ +.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))} +.post-card{display:flex;flex-direction:column;gap:14px} +.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block} +.post-card h3{margin:0;font-size:19px;line-height:1.35} +.post-card h3 a{text-decoration:none} +.post-card h3 a:hover{text-decoration:underline} +.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0} +.post-header{padding-block:22px 0} +.post-excerpt{margin:0;line-height:1.65} +.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px} +.post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px} +.pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px} +.post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 85b9dda..4509c33 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -9,7 +9,7 @@ final class DocumentValidator public function validate(array $document): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5], 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.'); @@ -34,7 +34,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) || $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) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); $props = $block['props'] ?? []; $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); @@ -61,6 +61,16 @@ final class DocumentValidator $this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.'); $headings += 1 === $props['level'] ? 1 : 0; } + if ('postlist' === $type) { + // The list is filled by the renderer, never by stored content: the block only says + // how many posts to show and which term to limit them to. + $this->require(is_int($props['count'] ?? null) && $props['count'] >= 1 && $props['count'] <= 24, 'Die Anzahl der Beiträge liegt zwischen 1 und 24.'); + foreach (['category', 'tag'] as $kind) { + if (isset($props[$kind]) && '' !== $props[$kind]) { + $this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.'); + } + } + } if ('button' === $type) { $this->url($props['href'] ?? null); } diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php index 2c2eae9..b1c1bce 100644 --- a/src/Content/PageRepository.php +++ b/src/Content/PageRepository.php @@ -9,7 +9,7 @@ use Doctrine\DBAL\Connection; final class PageRepository { /** Reserved because the application already answers under these paths. */ - public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon']; + public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege']; public const MAX_DEPTH = 4; public function __construct(private Connection $db, private DocumentValidator $validator) @@ -88,6 +88,7 @@ final class PageRepository } // Detach both pointers first: the revision rows are referenced by the page row. $this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]); + $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]); }); @@ -131,13 +132,18 @@ final class PageRepository if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before publishing.'); } + // A post that goes public without its own date gets one, so the archive can order it. + $this->db->executeStatement( + "UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL", + [gmdate('Y-m-d H:i:s'), $pageId], + ); }); } public function findPublished(string $path, string $locale = 'de'): ?array { $page = $this->db->fetchAssociative( - 'SELECT p.id, p.slug, p.path, p.type, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?', + 'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?', [$path, $locale, 'default'], ); if (false === $page) { diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php new file mode 100644 index 0000000..4896f1d --- /dev/null +++ b/src/Content/PostRepository.php @@ -0,0 +1,186 @@ + 'Kategorie', 'tag' => 'Schlagwort']; + public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort']; + public const PER_PAGE = 10; + + public function __construct(private Connection $db, private MediaLibrary $media) + { + } + + /** + * @param list $categories term names, created on first use + * @param list $tags term names, created on first use + */ + public function saveMetadata(string $pageId, array $values, array $categories, array $tags, int $expectedVersion): void + { + $excerpt = trim((string) ($values['excerpt'] ?? '')); + if (mb_strlen($excerpt) > 500) { + throw new InvalidPage('Der Auszug darf höchstens 500 Zeichen lang sein.'); + } + $author = trim((string) ($values['author'] ?? '')); + if (mb_strlen($author) > 120) { + throw new InvalidPage('Der Autorname darf höchstens 120 Zeichen lang sein.'); + } + $date = trim((string) ($values['publishedAt'] ?? '')); + if ('' !== $date && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?$/D', $date)) { + throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.'); + } + $mediaId = trim((string) ($values['featuredMediaId'] ?? '')); + if ('' !== $mediaId && (1 !== preg_match('/^[a-f0-9]{32}$/D', $mediaId) || null === $this->media->find($mediaId))) { + throw new InvalidPage('Das gewählte Beitragsbild gibt es nicht.'); + } + + $this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $categories, $tags, $expectedVersion): void { + $changed = $this->db->executeStatement( + 'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, version = version + 1 WHERE id = ? AND version = ?', + [ + '' === $excerpt ? null : $excerpt, + '' === $author ? null : $author, + '' === $mediaId ? null : $mediaId, + '' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00', + $pageId, + $expectedVersion, + ], + ); + if (1 !== $changed) { + throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); + } + $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); + foreach (['category' => $categories, 'tag' => $tags] as $kind => $names) { + foreach ($names as $name) { + $this->db->insert('page_term', ['page_id' => $pageId, 'term_id' => $this->term($kind, $name)]); + } + } + }); + } + + /** Finds or creates a term and returns its ID. Names are matched by their slug. */ + private function term(string $kind, string $name, string $locale = 'de'): string + { + $name = trim($name); + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'])) ?? '', '-'); + if ('' === $slug || mb_strlen($name) > 120) { + throw new InvalidPage(sprintf('„%s“ ist als %s nicht verwendbar.', $name, self::KINDS[$kind])); + } + $existing = $this->db->fetchOne('SELECT id FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, substr($slug, 0, 80)]); + if (false !== $existing) { + return $existing; + } + $id = bin2hex(random_bytes(16)); + $this->db->insert('term', ['id' => $id, 'website_id' => 'default', 'locale' => $locale, 'kind' => $kind, 'slug' => substr($slug, 0, 80), 'name' => $name]); + + return $id; + } + + /** Stamps the publication date when a post first goes public and has no date of its own. */ + public function stampPublication(string $pageId): void + { + $this->db->executeStatement( + "UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL", + [gmdate('Y-m-d H:i:s'), $pageId], + ); + } + + /** + * @return array{posts: list, total: int, pages: int, page: int} + */ + public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array + { + $where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL"; + $params = ['default', $locale]; + $join = ''; + if (null !== $termId) { + $join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?'; + array_unshift($params, $termId); + } + $total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page p'.$join.' WHERE '.$where, $params); + $pages = max(1, (int) ceil($total / $perPage)); + $page = max(1, min($page, $pages)); + $rows = $this->db->fetchAllAssociative( + 'SELECT p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p' + .$join.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE '.$where + // A missing date sorts last, then newest first, with the path as a stable tiebreaker. + .' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC LIMIT '.$perPage.' OFFSET '.(($page - 1) * $perPage), + $params, + ); + + return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page]; + } + + private function present(array $row): array + { + $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); + $image = null; + if (null !== $row['featured_media_id']) { + $item = $this->media->find($row['featured_media_id']); + $image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']]; + } + + return [ + 'title' => $document['title'] ?? $row['path'], + 'href' => '/'.$row['path'], + 'excerpt' => $row['excerpt'] ?? $document['description'] ?? '', + 'author' => $row['author'], + 'publishedAt' => $row['published_at'], + 'image' => $image, + 'terms' => $this->termsFor($row['id']), + ]; + } + + /** + * @return array> + */ + public function termsFor(string $pageId): array + { + $terms = ['category' => [], 'tag' => []]; + foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) { + $terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; + } + + return $terms; + } + + public function findTerm(string $kind, string $slug, string $locale = 'de'): ?array + { + $row = $this->db->fetchAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, $slug]); + + return false === $row ? null : $row; + } + + /** @return list */ + public function allTerms(string $locale = 'de'): array + { + return $this->db->fetchAllAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? ORDER BY kind, name', ['default', $locale]); + } + + /** Term names of one page, as the comma separated list the metadata form edits. */ + public function namesFor(string $pageId): array + { + $terms = $this->termsFor($pageId); + + return [ + 'category' => implode(', ', array_column($terms['category'], 'name')), + 'tag' => implode(', ', array_column($terms['tag'], 'name')), + ]; + } + + /** Removes term assignments of a deleted page. Unused terms stay; they are reusable labels. */ + public function forget(string $pageId): void + { + $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 4b5053b..ffc61ef 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\PostRepository; use App\Domain\InvalidDocument; use App\Domain\InvalidPage; use App\Domain\StaleWrite; @@ -19,7 +20,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 CsrfTokenManagerInterface $csrf) + public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private CsrfTokenManagerInterface $csrf) { } @@ -30,6 +31,9 @@ final class EditorController extends AbstractController foreach ($pages as &$page) { $page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug']; $page['depth'] = substr_count((string) $page['path'], '/'); + if ('post' === $page['type']) { + $page['terms'] = $this->posts->namesFor($page['id']); + } } unset($page); @@ -64,6 +68,38 @@ final class EditorController extends AbstractController return $this->redirectToRoute('admin_editor', ['id' => $id]); } + #[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])] + public function metadata(Request $request, string $id): Response + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + $terms = static function (string $raw): array { + $names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n); + + return array_values(array_unique($names)); + }; + try { + $this->posts->saveMetadata( + $id, + [ + 'excerpt' => $request->request->getString('excerpt'), + 'author' => $request->request->getString('author'), + 'publishedAt' => $request->request->getString('publishedAt'), + 'featuredMediaId' => $request->request->getString('featuredMediaId'), + ], + $terms($request->request->getString('categories')), + $terms($request->request->getString('tags')), + $request->request->getInt('expectedVersion', -1), + ); + } catch (InvalidPage|StaleWrite $e) { + return $this->back($e->getMessage()); + } + + return $this->back('Die Beitragsdaten wurden gespeichert.', 'success'); + } + #[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])] public function delete(Request $request, string $id): Response { diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 368f758..30deb6b 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Content\MenuRepository; use App\Content\PageRepository; +use App\Content\PostRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -11,13 +12,13 @@ use Symfony\Component\Routing\Attribute\Route; final class PageController extends AbstractController { - public function __construct(private PageRepository $pages, private MenuRepository $menus, private string $siteOrigin) + public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private string $siteOrigin) { } // A segment is a slug; up to four of them form the public path. The leading lookahead keeps // the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them. - private const PATH = '(?!admin(?:/|$)|media/|assets/)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}'; + private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}'; #[Route('/', name: 'home', methods: ['GET'])] #[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])] @@ -35,8 +36,17 @@ final class PageController extends AbstractController 'page' => $page, 'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path), 'navigation' => $this->navigation($path), + 'postLists' => $this->postLists($page['document']), + 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, ]); } + + return $this->shell($response); + } + + /** Every public response carries the same headers, whether page, archive or feed. */ + private function shell(Response $response): Response + { $response->headers->set('Cache-Control', 'no-store'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); @@ -45,6 +55,75 @@ final class PageController extends AbstractController return $response; } + #[Route('/beitraege', name: 'posts', methods: ['GET'])] + #[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] + #[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] + public function archive(Request $request, ?string $slug = null): Response + { + $kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag'; + $term = null; + if (null !== $slug) { + $term = $this->posts->findTerm($kind, $slug); + if (null === $term) { + return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404))); + } + } + $page = max(1, $request->query->getInt('seite', 1)); + $result = $this->posts->published($page, $term['id'] ?? null); + $base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug']; + + return $this->shell($this->render('archive.html.twig', [ + 'term' => $term, + 'heading' => null === $term ? 'Beiträge' : $term['name'], + 'result' => $result, + 'base' => $base, + 'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''), + 'navigation' => $this->navigation(''), + ])); + } + + #[Route('/feed.xml', name: 'feed', methods: ['GET'])] + public function feed(): Response + { + $result = $this->posts->published(1, null, 20); + $response = $this->shell($this->render('feed.xml.twig', [ + 'posts' => $result['posts'], + 'origin' => rtrim($this->siteOrigin, '/'), + ])); + $response->headers->set('Content-Type', 'application/xml; charset=UTF-8'); + + return $response; + } + + /** + * Collects the posts each post list block needs. Stored content never carries the posts + * themselves, so a saved document cannot claim a post it is not allowed to show. + * + * @return array> + */ + private function postLists(array $document): array + { + $lists = []; + $walk = function (array $blocks) use (&$walk, &$lists): void { + foreach ($blocks as $block) { + if ('postlist' === ($block['type'] ?? null)) { + $props = $block['props'] ?? []; + $term = null; + foreach (['category', 'tag'] as $kind) { + if ('' !== ($props[$kind] ?? '')) { + $term = $this->posts->findTerm($kind, $props[$kind]); + } + } + $lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts']; + } + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $lists; + } + /** * @return array> */ diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index a53f8ac..d1a98f0 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -17,7 +17,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'} %}{% endfor %} + {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste'} %}{% endfor %}

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 4e1d3b1..c15a431 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 }}

@@ -63,6 +63,30 @@
{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }} Bearbeiten + {% if page.type == 'post' and is_granted('ROLE_EDITOR') %} +
+ Beitragsdaten +
+ + + + + + + + +

Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.

+ + + + +

Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.

+ + + +
+
+ {% endif %} {% if is_granted('ROLE_ADMIN') %}
Löschen diff --git a/templates/archive.html.twig b/templates/archive.html.twig new file mode 100644 index 0000000..4781aaa --- /dev/null +++ b/templates/archive.html.twig @@ -0,0 +1,53 @@ + + + + + + {{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %} · Beiträge + + + {% if result.page > 1 %}{% endif %} + {% if result.page < result.pages %}{% endif %} + + + + + + + + +
+
+
+ {% if term %}

{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }}

{% endif %} +

{{ heading }}

+

{{ result.total }} {{ result.total == 1 ? 'Beitrag' : 'Beiträge' }}{% if result.pages > 1 %} · Seite {{ result.page }} von {{ result.pages }}{% endif %}

+
+
+
+
+ {% include 'blocks/post-list.html.twig' with {posts: result.posts} only %} + {% if result.pages > 1 %} + + {% endif %} +
+
+
+
+ kantcms + {% if navigation.footer|default([]) is not empty %} + + {% endif %} + Lokaler Entwicklungsprototyp · 2026 +
+ + diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index e192597..25c2d1b 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -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' %}
{% endif %} - {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false)} only %}{% endfor %} + {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} {% if type == 'section' %}
{% endif %} {% elseif type == 'heading' %} @@ -21,9 +21,13 @@ {{ props.text }} {% elseif type == 'image' %}
{{ props.alt }}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
+{% elseif type == 'postlist' %} +
+ {% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %} +
{% elseif type == 'gallery' %}
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false)} only %}{% endfor %} + {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
{% else %} {# diff --git a/templates/blocks/post-list.html.twig b/templates/blocks/post-list.html.twig new file mode 100644 index 0000000..5d85818 --- /dev/null +++ b/templates/blocks/post-list.html.twig @@ -0,0 +1,30 @@ +{# Posts are supplied by the controller, never read from stored content. #} +{% if posts is empty %} +

Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.

+{% else %} +
    + {% for post in posts %} +
  • + {% if post.image %} + + {% endif %} +
    +

    {{ post.title }}

    + {% if post.publishedAt or post.author %} + + {% endif %} + {% if post.excerpt %}

    {{ post.excerpt }}

    {% endif %} + {% if post.terms.category is not empty %} +

    {% for term in post.terms.category %}{{ term.name }}{% endfor %}

    + {% endif %} +
    +
  • + {% endfor %} +
+{% endif %} diff --git a/templates/feed.xml.twig b/templates/feed.xml.twig new file mode 100644 index 0000000..4fd246c --- /dev/null +++ b/templates/feed.xml.twig @@ -0,0 +1,20 @@ + + + + Beiträge + {{ origin }}/beitraege + + Die neuesten veröffentlichten Beiträge. + de +{% for post in posts %} + + {{ post.title }} + {{ origin }}{{ post.href }} + {{ origin }}{{ post.href }} + {% if post.publishedAt %}{{ post.publishedAt|date('r') }}{% endif %} + {% if post.excerpt %}{{ post.excerpt }}{% endif %} + {% for term in post.terms.category %}{{ term.name }}{% endfor %} + +{% endfor %} + + diff --git a/templates/page.html.twig b/templates/page.html.twig index 2832426..418b341 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -8,6 +8,7 @@ + {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} @@ -20,7 +21,15 @@ {% endif %}
- {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false)} only %}{% endfor %} + {% if postMeta is defined and postMeta is not null %} +

+ {% if page.published_at %}{% endif %} + {% if page.author %}von {{ page.author }}{% endif %} + {% for term in postMeta.category %}{{ term.name }}{% endfor %} + {% for term in postMeta.tag %}{% endfor %} +

+ {% endif %} + {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}