diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d463cf5..fde7357 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -162,3 +162,19 @@ Kategorien und Schlagwörter teilen sich die Tabelle `term` mit einem Feld `kind 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. + +## ADR 015 – Blöcke für die Pilotseite (Dokumentschema v6, 12.09.2026) + +Die Startseite von kant-it.de besteht aus Abschnittsarten, die sich aus Sektion, Zeile, Spalte, Überschrift und Text nicht sinnvoll nachbauen lassen. Schema v6 ergänzt deshalb zehn Blöcke: `iconcard`, `stat`, `quote`, `logos`, `cta`, `contact`, `divider`, `spacer`, `accordion` und `accordionitem`. + +**Symbole liegen als Inline-SVG aus einer festen Liste im Template.** Kein Symbolschriftsatz, keine externe Anfrage, kein zusätzliches Stylesheet. Der Validator lässt nur Namen aus `DocumentValidator::ICONS` zu; die Liste im Editor spiegelt dieselben Werte, verbindlich ist der Server. + +`contact` gibt Telefon und E-Mail als `tel:`- und `mailto:`-Verweise aus. Die Telefonnummer wird für das Linkziel von Leerzeichen und Trennzeichen befreit, bleibt aber lesbar dargestellt. Ein Kontaktblock ohne jede Angabe wird abgewiesen, statt ein leeres Element zu erzeugen. + +`accordion` und `accordionitem` nutzen `details`/`summary` und funktionieren damit ohne öffentliches JavaScript und mit der Tastatur. Ein Akkordeon enthält ausschließlich Einträge, ein Eintrag enthält gewöhnliche Inhalte – und ein Eintrag außerhalb eines Akkordeons wird abgewiesen, auch auf oberster Dokumentebene. Diese Prüfung sitzt deshalb an der Typprüfung des Blocks und nicht nur beim Durchlaufen der Kinder; ein Test hatte gezeigt, dass die Regel sonst genau am Wurzelknoten nicht greift. + +**Tabs sind bewusst nicht umgesetzt.** Ohne JavaScript gibt es dafür keine zugängliche Lösung: eine CSS-Variante über Radiobuttons ist zwar bedienbar, wird von Screenreadern aber als Auswahlfeld angekündigt und nicht als Tabliste. Da Barrierefreiheit im Plan Vorrang vor Funktionsumfang hat, ist ein Akkordeon hier die ehrlichere Antwort auf denselben Bedarf. Tabs bleiben offen, bis entweder gezielt JavaScript für die Verwaltung dieses Musters vorgesehen ist oder ein tragfähiger Ansatz ohne feststeht. + +`logos` verhält sich wie `gallery`: ein Container, der ausschließlich Bilder aufnimmt. Die Beschränkung steht im Validator und wird im Editormodell (`ONLY_CHILD`) gespiegelt, damit die Oberfläche gar nicht erst etwas anbietet, was der Server ablehnen würde. + +Die neuen Typen sind im Tokenmanifest für Außenabstände eingetragen, teils auch für Ausrichtung, Ecken, Spalten und Innenabstand. Die generierte `design.css` ändert sich dadurch nicht, weil sie je Wert und nicht je Blocktyp erzeugt wird. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 70f89d2..bb10f5a 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -74,10 +74,12 @@ Beitragsdatum, Autor, Auszug und Beitragsbild im Datenmodell. Übersichtsseite m 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. -### C – Blöcke für den Piloten +### C – Blöcke für den Piloten — **erledigt am 12.09.2026, ohne Tabs** *Braucht: nichts; parallel möglich.* +Umgesetzt: Symbolkarte, Kennzahl, Kundenstimme, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand und Akkordeon, alle ohne neue öffentliche JavaScript-Abhängigkeit. **Tabs bewusst nicht umgesetzt:** ohne JavaScript gibt es dafür keine zugängliche Lösung, und Barrierefreiheit hat im Plan Vorrang vor Funktionsumfang. Begründung und Rückweg in ADR 015. + Die oben erhobenen Abschnittsarten als Blöcke: Symbol-und-Text-Karte, Kennzahl, Kundenstimme mit Bewertung, Logoleiste, Handlungsaufruf-Band, Kontaktdatenblock, Trennlinie und Abstandhalter. Dazu Akkordeon und Tabs, weil beide im Plan stehen und ohne JavaScript über `details`/`summary` beziehungsweise Sprungmarken lösbar sind. Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffentliche JavaScript-Abhängigkeit. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 5b91930..3f15041 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,15 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Blöcke für die Pilotseite, 12.09.2026 + +- **107 PHP-Tests / 380 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **13 JavaScript-Modelltests**. +- Eine Testseite aus allen zehn neuen Abschnittsarten wird Byte für Byte gespeichert, veröffentlicht und ausgegeben. Geprüft sind die erzeugten `tel:`- und `mailto:`-Verweise samt bereinigter Telefonnummer, das Akkordeon als echtes `details`-Element mit `summary`, Symbole als Inline-SVG, die für Screenreader ausgeschriebene Bewertung und die Abwesenheit von `script` in der Ausgabe. +- Vierzehn abgewiesene Eingaben schreiben nichts: unbekanntes Symbol, fehlender Titel, `javascript:`-Linkziel, Bewertung außerhalb 0–5 sowie als Zeichenkette statt Zahl, leerer Kontaktblock, ungültige Telefonnummer, ungültige E-Mail-Adresse, unbekannte Abstandsgröße, unerlaubtes Linkschema am Aufruf-Band, Text in einer Logoleiste, Text direkt in einem Akkordeon, Akkordeon-Eintrag ohne Akkordeon und Kinder an einer Trennlinie. Zusätzlich abgesichert, dass die neuen Blöcke in Schema v5 weiterhin abgewiesen werden. +- **Zwei echte Fehler wurden durch diese Tests gefunden und behoben.** Im Validator griff die Regel „ein Akkordeon-Eintrag gehört in ein Akkordeon“ nicht auf oberster Dokumentebene, weil sie nur beim Durchlaufen der Kinder lief; sie sitzt jetzt an der Typprüfung selbst. Im JavaScript-Test wurde `history.document.blocks` vor `history.change()` ausgewertet und damit im vorherigen Dokumentstand gesucht – ein Fehler im Test, nicht im Modell, aber einer, der bei umgekehrtem Ausgang eine falsche Bestätigung erzeugt hätte. +- Im Editormodell geprüft: ein neues Akkordeon bringt einen Eintrag mit, damit es nie leer ausgegeben wird; ein Eintrag lässt sich nur in ein Akkordeon einfügen und nirgendwo sonst; eine Logoleiste nimmt nur Bilder; Blattblöcke bleiben Blätter und behalten ihre Vorgabewerte; das vollständige Rückgängigmachen stellt den Ausgangszustand her. +- `composer lint`, `lint:twig`, `lint:yaml`, `lint:container` und die Syntaxprüfung der Editormodule sind erfolgreich. Die erzeugte `design.css` bleibt unverändert, obwohl das Tokenmanifest neue Typen führt, und der bestehende Abgleich zwischen Manifest und Stylesheet greift weiterhin. +- **Auch für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Für die zehn neuen Blöcke liegt damit **keine visuelle Kontrolle, keine Barrierefreiheitsprüfung und keine Messung** vor. Das wiegt hier schwerer als in den Schritten davor, weil es sich um sichtbare Gestaltungsbausteine mit eigenem CSS handelt; die Aussagen oben betreffen ausschließlich die erzeugte Auszeichnung. + ## 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**. diff --git a/docs/STATUS.md b/docs/STATUS.md index c3b7914..f274752 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -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. +Stand: 12.09.2026. Arbeitstitel Kant CMS. **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,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. +- Dokumentschema v6 mit zehn Blöcken für die Pilotseite: Symbolkarte, Kennzahl, Kundenstimme mit Bewertung, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand, Akkordeon und Akkordeon-Eintrag. Symbole als Inline-SVG aus einer festen Liste, kein Symbolschriftsatz und keine externe Anfrage. Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen. Akkordeon über `details`/`summary`, also ohne öffentliches JavaScript. **Tabs sind bewusst nicht umgesetzt** – ohne JavaScript gibt es dafür keine zugängliche Lösung; Begründung in ADR 015. - 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. @@ -33,8 +34,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. ## Geprüft -- 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. +- 107 PHP-Tests / 380 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 13 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. +- Neue Blöcke geprüft: eine Seite aus allen zehn Abschnittsarten wird unverändert gespeichert, veröffentlicht und ausgegeben; Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen mit bereinigter Nummer, das Akkordeon ist ein echtes `details`-Element, Symbole sind Inline-SVG, die Bewertung ist für Screenreader ausgeschrieben, und die Ausgabe enthält kein `script`. Vierzehn abgewiesene Eingaben schreiben nichts: unbekanntes Symbol, fehlender Titel, `javascript:`-Ziel, Bewertung außerhalb 0–5 und als Zeichenkette, leerer Kontaktblock, ungültige Telefonnummer und E-Mail-Adresse, unbekannte Abstandsgröße, unerlaubtes Linkschema, Text in Logoleiste und Akkordeon, Akkordeon-Eintrag ohne Akkordeon sowie Kinder an einer Trennlinie. Zusätzlich geprüft, dass die neuen Blöcke in Schema v5 weiterhin abgewiesen werden. - 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. @@ -58,6 +60,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. +- Tabs fehlen bewusst (siehe oben). Die neuen Blöcke lassen sich nicht in einer Vorlage bündeln: die drei Abschnittsvorlagen sind unverändert. Die Logoleiste hat keine Graustufen- oder Größenoption, die Kundenstimme kein Bild, die Symbolkarte keine eigene Farbe. - 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. @@ -70,7 +73,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. ## Nächster konkreter Schritt -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. +Arbeitspaket D aus `docs/AUSBAUPLAN.md`: das Formularsystem – Feldtypen, serverseitige Validierung mit Fehlermeldungen am Feld, Einwilligung, E-Mail-Versand, gespeicherte Einsendungen und Spam-Schutz ohne Drittanbieter-CAPTCHA. Ohne das ist der Pilot nicht livefähig. `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/public/assets/design-tokens.json b/public/assets/design-tokens.json index 44f9f3f..8ad324a 100644 --- a/public/assets/design-tokens.json +++ b/public/assets/design-tokens.json @@ -6,7 +6,11 @@ "row", "column", "heading", - "text" + "text", + "iconcard", + "stat", + "quote", + "cta" ], "values": { "left": [ @@ -105,7 +109,8 @@ "label": "Innenabstand oben / unten", "types": [ "section", - "column" + "column", + "accordionitem" ], "values": { "none": [ @@ -133,7 +138,8 @@ "paddingX": { "label": "Innenabstand links / rechts", "types": [ - "column" + "column", + "accordionitem" ], "values": { "none": [ @@ -164,7 +170,18 @@ "text", "image", "button", - "gallery" + "gallery", + "iconcard", + "stat", + "quote", + "cta", + "contact", + "divider", + "spacer", + "postlist", + "logos", + "accordion", + "accordionitem" ], "values": { "none": [ @@ -195,7 +212,18 @@ "text", "image", "button", - "gallery" + "gallery", + "iconcard", + "stat", + "quote", + "cta", + "contact", + "divider", + "spacer", + "postlist", + "logos", + "accordion", + "accordionitem" ], "values": { "none": [ @@ -220,7 +248,9 @@ "label": "Spaltenabstand", "types": [ "row", - "gallery" + "gallery", + "logos", + "accordion" ], "values": { "none": [ @@ -245,7 +275,8 @@ "label": "Spaltenaufteilung", "types": [ "row", - "gallery" + "gallery", + "logos" ], "values": { "auto": [ @@ -275,7 +306,10 @@ "types": [ "column", "image", - "button" + "button", + "iconcard", + "quote", + "cta" ], "values": { "none": [ diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 9cb3fb5..5d541a0 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -1,14 +1,18 @@ -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: '☷' }; +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' }; +export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·' }; // 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}$/; export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type); export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt'); export const blockIcon = block => icons[block?.type] ?? '❔'; -export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type); +export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem'].includes(block?.type); // Nothing may be dropped into a plugin block: the core does not know its layout contract. -export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) && (parent.type !== 'gallery' || block.type === 'image'); +// Containers that accept only one kind of child, mirrored by the server side validator. +export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem' }; +export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) + && (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type) + && (block.type !== 'accordionitem' || parent.type === 'accordion'); export const clone = value => structuredClone(value); export function locate(blocks, id, parent = null) { @@ -25,10 +29,18 @@ export function createBlock(type) { if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.'); const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type }; if (isContainer(block)) block.children = []; + if (type === 'accordion') block.children = [createBlock('accordionitem')]; 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 === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' }; + if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' }; + if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 }; + if (type === 'cta') block.props = { title: 'Bereit für den nächsten Schritt?', text: '', buttonText: 'Jetzt anfragen', href: '/' }; + if (type === 'contact') block.props = { phone: '', email: '', address: '' }; + if (type === 'spacer') block.props = { size: 'medium' }; + if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } @@ -37,6 +49,7 @@ export function insertBlock(document, type, selected) { const block = createBlock(type); if (type === 'gallery') upgradeDocument(document, 3); if (type === 'postlist') upgradeDocument(document, 5); + if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); 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); @@ -78,7 +91,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].includes(document.schemaVersion) || ![2, 3, 4, 5].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5, 6].includes(document.schemaVersion) || ![2, 3, 4, 5, 6].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } @@ -95,7 +108,7 @@ export function effectiveDesign(block, device, key) { } export function setDesign(document, id, device, key, value) { - if (![1, 2, 3, 4, 5].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4, 5, 6].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 4d0f708..91371c9 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -1,6 +1,9 @@ import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js'; import { openMedia } from './media.js'; +// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list. +const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check']; + const root = document.querySelector('#editor'); const $ = selector => document.querySelector(selector); const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`; @@ -170,6 +173,38 @@ 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 === 'iconcard') { + field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); + field('Titel', 'title', block.props.title, { maxLength: 120 }); + field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); + field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' }); + } + if (block.type === 'stat') { + field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' }); + field('Beschriftung', 'label', block.props.label, { maxLength: 80 }); + } + if (block.type === 'quote') { + field('Zitat', 'text', block.props.text, { multiline: true, maxLength: 1000 }); + field('Name', 'author', block.props.author, { maxLength: 120 }); + field('Rolle oder Firma', 'role', block.props.role || '', { maxLength: 120 }); + field('Bewertung', 'rating', block.props.rating, { number: true, choices: { 0: 'Keine Sterne', 1: '1 Stern', 2: '2 Sterne', 3: '3 Sterne', 4: '4 Sterne', 5: '5 Sterne' } }); + } + if (block.type === 'cta') { + field('Titel', 'title', block.props.title, { maxLength: 160 }); + field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); + field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 }); + field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); + } + if (block.type === 'contact') { + field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' }); + field('E-Mail', 'email', block.props.email || '', { maxLength: 80 }); + 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 === 'accordionitem') { + field('Titel', 'title', block.props.title, { maxLength: 160 }); + field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true }); + } 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.' }); diff --git a/public/assets/site.css b/public/assets/site.css index 5f80cca..fbdfa27 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -30,3 +30,36 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto} .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} + +/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */ +.block-icon{flex-shrink:0;display:block} +.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px} +.block-iconcard h3{margin:0;font-size:19px;line-height:1.3} +.block-iconcard h3 a{text-decoration:none} +.block-iconcard h3 a:hover{text-decoration:underline} +.block-iconcard p{margin:0;line-height:1.65} +.block-stat{display:grid;gap:4px;margin:0} +.block-stat strong{font-size:44px;font-weight:600;line-height:1.05;letter-spacing:-1.5px} +.block-stat span{font-size:14px;line-height:1.5} +.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px} +.block-quote blockquote{margin:0} +.block-quote blockquote p{margin:0;font-size:18px;line-height:1.65} +.block-quote figcaption{font-size:14px;font-weight:600} +.block-quote figcaption span{font-weight:400;opacity:.75} +.quote-rating{margin:0;letter-spacing:3px} +.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px} +.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block} +.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px} +.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px} +.block-cta p{margin:0;line-height:1.6} +.block-contact{display:grid;gap:14px;font-style:normal} +.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5} +.block-divider{border:0;border-top:1px solid currentColor;opacity:.35;margin:0} +.block-spacer{display:block} +.spacer-small{height:18px}.spacer-medium{height:38px}.spacer-large{height:68px}.spacer-huge{height:110px} +.block-accordion{display:grid;gap:10px} +.block-accordionitem{border:1px solid currentColor;border-radius:10px;padding:4px 18px} +.block-accordionitem>summary{cursor:pointer;padding:15px 0;font-weight:600;list-style:none;display:flex;justify-content:space-between;gap:18px;align-items:center} +.block-accordionitem>summary::after{content:'+';font-size:22px;line-height:1} +.block-accordionitem[open]>summary::after{content:'–'} +.accordion-body{padding-bottom:16px} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 4509c33..e69e065 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -6,10 +6,16 @@ use App\Domain\InvalidDocument; final class DocumentValidator { + /** Blocks introduced with schema v6 for the sections a marketing page actually needs. */ + public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem']; + /** Icons ship as inline SVG from this fixed list; no icon font and no external request. */ + public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check']; + public const SPACER_SIZES = ['small', 'medium', 'large', 'huge']; + public function validate(array $document): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6], 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.'); @@ -17,12 +23,12 @@ final class DocumentValidator $ids = []; $headings = 0; foreach ($document['blocks'] as $block) { - $this->block($block, $ids, $headings, 0, $schemaVersion); + $this->block($block, $ids, $headings, 0, $schemaVersion, null); } $this->require(1 === $headings, 'A page requires exactly one main heading.'); } - private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion): void + private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void { $this->require(is_array($block) && $depth <= 10 && count($ids) < 300, 'Invalid block or document complexity limit exceeded.'); $id = $block['id'] ?? null; @@ -34,7 +40,10 @@ 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) || $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)) || $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.'); $props = $block['props'] ?? []; $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); @@ -74,6 +83,50 @@ final class DocumentValidator if ('button' === $type) { $this->url($props['href'] ?? null); } + if ('iconcard' === $type) { + $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); + $this->text($props['title'] ?? null, 120); + $this->text($props['text'] ?? null, 600, true); + if ('' !== ($props['href'] ?? '')) { + $this->url($props['href']); + } + } + if ('stat' === $type) { + $this->text($props['value'] ?? null, 16); + $this->text($props['label'] ?? null, 80); + } + if ('quote' === $type) { + $this->text($props['text'] ?? null, 1000); + $this->text($props['author'] ?? null, 120); + $this->text($props['role'] ?? null, 120, true); + $rating = $props['rating'] ?? 0; + $this->require(is_int($rating) && $rating >= 0 && $rating <= 5, 'A rating is a whole number between 0 and 5.'); + } + if ('cta' === $type) { + $this->text($props['title'] ?? null, 160); + $this->text($props['text'] ?? null, 600, true); + $this->text($props['buttonText'] ?? null, 80); + $this->url($props['href'] ?? null); + } + if ('contact' === $type) { + foreach (['phone', 'email', 'address'] as $field) { + $this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true); + } + $this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.'); + if ('' !== $props['phone']) { + $this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.'); + } + if ('' !== $props['email']) { + $this->require(false !== filter_var($props['email'], FILTER_VALIDATE_EMAIL), 'Invalid email address.'); + } + } + if ('spacer' === $type) { + $this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.'); + } + if ('accordionitem' === $type) { + $this->text($props['title'] ?? null, 160); + $this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.'); + } if ('image' === $type) { $this->url($props['src'] ?? null); $this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.'); @@ -107,13 +160,15 @@ final class DocumentValidator $children = $block['children'] ?? []; $this->require(is_array($children) && array_is_list($children), 'Children must be a list.'); // A plugin block may nest content; keeping its children is part of preserving the page. - $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery'], true); + $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem'], true); $this->require($container || [] === $children, 'Leaf blocks cannot contain children.'); + // Containers that only accept one kind of child, so the rendered markup stays valid. + $only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem']; foreach ($children as $child) { - if ('gallery' === $type) { - $this->require(is_array($child) && 'image' === ($child['type'] ?? null), 'Galleries only contain images.'); + if (isset($only[$type])) { + $this->require(is_array($child) && $only[$type] === ($child['type'] ?? null), sprintf('A %s only contains %s blocks.', $type, $only[$type])); } - $this->block($child, $ids, $headings, $depth + 1, $schemaVersion); + $this->block($child, $ids, $headings, $depth + 1, $schemaVersion, $type); } } diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index d1a98f0..4244cd0 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', postlist:'Beitragsliste'} %}{% 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', divider:'Trennlinie', spacer:'Abstand'} %}{% endfor %}

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 25c2d1b..d518eeb 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -21,6 +21,55 @@ {{ props.text }} {% elseif type == 'image' %}
{{ props.alt }}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
+{% elseif type == 'iconcard' %} +
+ {% include 'blocks/icon.html.twig' with {name: props.icon} only %} +

{% if props.href|default('') %}{{ props.title }}{% else %}{{ props.title }}{% endif %}

+ {% if props.text|default('') %}

{{ props.text }}

{% endif %} +
+{% elseif type == 'stat' %} +

{{ props.value }}{{ props.label }}

+{% elseif type == 'quote' %} +
+ {% if props.rating|default(0) > 0 %} +

Bewertung: {{ props.rating }} von 5

+ {% endif %} +

{{ props.text }}

+
{{ props.author }}{% if props.role|default('') %} {{ props.role }}{% endif %}
+
+{% elseif type == 'logos' %} + +{% elseif type == 'cta' %} +
+
+

{{ props.title }}

+ {% if props.text|default('') %}

{{ props.text }}

{% endif %} +
+ {{ props.buttonText }} +
+{% elseif type == 'contact' %} +
+ {% if props.phone|default('') %}{% include 'blocks/icon.html.twig' with {name: 'phone'} only %}{{ props.phone }}{% endif %} + {% if props.email|default('') %}{% include 'blocks/icon.html.twig' with {name: 'mail'} only %}{{ props.email }}{% endif %} + {% if props.address|default('') %}{% include 'blocks/icon.html.twig' with {name: 'pin'} only %}{{ props.address }}{% endif %} +
+{% elseif type == 'divider' %} +
+{% elseif type == 'spacer' %} + +{% elseif type == 'accordion' %} +
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} +
+{% elseif type == 'accordionitem' %} +
+ {{ props.title }} +
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %} +
+
{% elseif type == 'postlist' %}
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %} diff --git a/templates/blocks/icon.html.twig b/templates/blocks/icon.html.twig new file mode 100644 index 0000000..cae93fe --- /dev/null +++ b/templates/blocks/icon.html.twig @@ -0,0 +1,18 @@ +{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #} +{% set paths = { + app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2', + web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6', + design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z', + network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8', + hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4', + shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0', + support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z', + shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5', + clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2', + star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z', + phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z', + mail: 'M3 6h18v12H3Zm0 1 9 7 9-7', + pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z', + check: 'm4 12.5 5.5 5.5L20 6' +} %} + diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 5ef5a0c..07299a1 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -666,6 +666,118 @@ final class CmsTest extends TestCase return $id; } + /** A page built from the section kinds the pilot start page actually uses. */ + private static function pilotDocument(): array + { + return [ + 'schemaVersion' => 6, + 'title' => 'Kant-IT', + 'description' => 'Ihr IT-Partner.', + 'blocks' => [ + ['id' => 'hero', 'type' => 'section', 'children' => [ + ['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]], + ['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']], + ['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']], + ['id' => 'hr', 'type' => 'divider'], + ]], + ['id' => 'leistungen', 'type' => 'section', 'children' => [ + ['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']], + ['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']], + ]], + ['id' => 'stimmen', 'type' => 'section', 'children' => [ + ['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]], + ['id' => 'logos', 'type' => 'logos', 'children' => [ + ['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]], + ]], + ]], + ['id' => 'fragen', 'type' => 'accordion', 'children' => [ + ['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [ + ['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']], + ]], + ]], + ['id' => 'kontakt', 'type' => 'section', 'children' => [ + ['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']], + ['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']], + ]], + ], + ]; + } + + public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void + { + $document = self::pilotDocument(); + $revision = $this->pages->saveDraft('page-1', $document, 0); + self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true)); + $this->pages->publish('page-1', $revision, 1); + + $html = $this->request('/')->getContent(); + self::assertSame(200, $this->request('/')->getStatusCode()); + // Every new block reaches the output. + foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) { + self::assertStringContainsString($marker, $html, $marker); + } + // Contact details become real links, with the phone number stripped for the href. + self::assertStringContainsString('href="tel:+4972219957150"', $html); + self::assertStringContainsString('href="mailto:info@kant-it.de"', $html); + // The accordion is a native details element, so it works without scripting. + self::assertStringContainsString('
', $html); + self::assertStringContainsString('Wie schnell antwortet ihr?', $html); + // Icons are inline SVG, not an icon font or an external request. + self::assertStringContainsString(' + */ + public static function rejectedV6Blocks(): iterable + { + $wrap = static function (array $block): array { + $document = DemoContent::starter('Titel'); + $document['schemaVersion'] = 6; + $document['blocks'][] = $block; + + return [$document]; + }; + yield 'unknown icon' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'rocket', 'title' => 'T', 'text' => '']]); + yield 'iconcard without title' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => ' ', 'text' => '']]); + yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]); + yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]); + yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]); + yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]); + yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]); + yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]); + yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]); + yield 'cta without target' => $wrap(['id' => 'x', 'type' => 'cta', 'props' => ['title' => 'T', 'text' => '', 'buttonText' => 'Los', 'href' => 'ftp://example.com']]); + yield 'logos with a text child' => $wrap(['id' => 'x', 'type' => 'logos', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'kein Logo']]]]); + yield 'accordion with a text child' => $wrap(['id' => 'x', 'type' => 'accordion', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]); + yield 'accordion entry outside an accordion' => $wrap(['id' => 'x', 'type' => 'accordionitem', 'props' => ['title' => 'T', 'open' => false], 'children' => []]); + yield 'divider with children' => $wrap(['id' => 'x', 'type' => 'divider', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'a']]]]); + } + + #[DataProvider('rejectedV6Blocks')] + public function testRejectsInvalidV6BlocksBeforeWriting(array $document): void + { + $this->expectException(InvalidDocument::class); + $this->pages->saveDraft('page-1', $document, 0); + } + + public function testV6BlocksNeedSchemaSixAndStayRejectedInOlderDocuments(): void + { + $document = DemoContent::starter('Titel'); + $document['schemaVersion'] = 5; + $document['blocks'][] = ['id' => 'karte', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '']]; + try { + $this->pages->saveDraft('page-1', $document, 0); + self::fail('Expected schema v5 to reject a v6 block.'); + } catch (InvalidDocument) { + } + $document['schemaVersion'] = 6; + self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0)); + } + private function request(string $url, string $method = 'GET'): Response { $previous = []; diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index 5889629..cd9ec85 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -176,3 +176,38 @@ test('the post list block carries only its filter, lifts the schema to v5 and un assert.deepEqual(history.document, fixture()); assert.equal(history.document.schemaVersion, 1); }); + +test('v6 containers accept only their own children and an accordion starts with one entry', () => { + const history = new EditorHistory(fixture()); + const accordion = history.change(doc => insertBlock(doc, 'accordion', 's2')); + const block = locate(history.document.blocks, accordion).block; + + assert.equal(history.document.schemaVersion, 6); + // An empty accordion would render as nothing, so it comes with one entry. + assert.equal(block.children.length, 1); + assert.equal(block.children[0].type, 'accordionitem'); + assert.deepEqual(block.children[0].props, { title: 'Eine Frage', open: false }); + + // Only accordion entries go into an accordion, and an entry goes nowhere else. + assert.equal(canContain(block, { type: 'text' }), false); + assert.equal(canContain(block, { type: 'accordionitem' }), true); + assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false); + assert.equal(canContain(block.children[0], { type: 'text' }), true); + + // A logo strip takes images only, like a gallery. + const logos = history.change(doc => insertBlock(doc, 'logos', 's2')); + const strip = locate(history.document.blocks, logos).block; + assert.equal(canContain(strip, { type: 'image' }), true); + assert.equal(canContain(strip, { type: 'iconcard' }), false); + + // Leaf blocks stay leaves and keep their defaults. + // Two statements on purpose: history.change() replaces the document, so reading + // history.document.blocks in the same expression would search the previous snapshot. + const statId = history.change(doc => insertBlock(doc, 'stat', 's2')); + const stat = locate(history.document.blocks, statId).block; + assert.equal(stat.children, undefined); + assert.deepEqual(stat.props, { value: '15+', label: 'Jahre Erfahrung' }); + + history.undo(); history.undo(); history.undo(); + assert.deepEqual(history.document, fixture()); +});