diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..937123e
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "kant-cms",
+ "runtimeExecutable": "php",
+ "runtimeArgs": ["-d", "upload_max_filesize=8M", "-d", "post_max_size=10M", "-d", "memory_limit=256M", "-S", "127.0.0.1:8080", "-t", "public"],
+ "port": 8080
+ }
+ ]
+}
diff --git a/config/services.yaml b/config/services.yaml
index 4c13007..20a8670 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -19,3 +19,6 @@ services:
arguments:
$secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%'
+ App\Controller\SeoController:
+ arguments:
+ $siteOrigin: '%env(SITE_ORIGIN)%'
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d40e0e2..faa328c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -210,3 +210,19 @@ Ablauf ohne JavaScript: eine fehlerhafte Absendung rendert dieselbe Seite erneut
**Dateiuploads sind nicht enthalten.** Anhänge brauchen einen eigenen, nicht öffentlichen Ablageort mit Zugriffsschutz, eine Typprüfung und eine Entscheidung über den Versand als Anhang – eine eigene Sicherheitsfläche, die nicht nebenbei entsteht. Das Formular des Piloten hat kein Dateifeld. Der Punkt bleibt im Ausbauplan offen.
Beim Schreiben der Tests fiel ein echter Fehler auf: das Stundenfenster des Limits wurde mit der lokalen Uhrzeit gebildet, die Zeitstempel der Einsendungen aber in UTC. Bei einer Uhr vor UTC hätte das Limit nie gegriffen. Beide Seiten rechnen jetzt in UTC.
+
+## ADR 018 – SEO, Weiterleitungen und strukturierte Daten (Dokumentschema v9, 12.09.2026)
+
+Migration `20260912000800` ergänzt `page.robots`, `page.og_media_id`, `website.settings` und die Tabelle `redirect`.
+
+**Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet, nichts davon separat gepflegt.** Die Sitemap listet ausschließlich veröffentlichte Seiten mit `robots = index`, dazu Beitragsübersicht und Begriffsarchive, und zwar nur solange diese etwas enthalten. Eine nicht indexierbare Seite verschwindet damit gleichzeitig aus der Sitemap und bekommt ein `noindex`-Meta – die beiden Aussagen können nicht auseinanderlaufen.
+
+Strukturierte Daten entstehen ebenfalls nur aus gepflegten Werten: `Organization` beziehungsweise `LocalBusiness` aus den Unternehmensdaten, `Article` aus den Beitragsmetadaten, `BreadcrumbList` aus der Seitenhierarchie. Solange nichts gepflegt ist, wird **kein** Block ausgegeben; ein leeres `Organization` wäre Rauschen, keine Angabe. Die Auszeichnung steht als `application/ld+json` im Kopf – ein Datenblock, kein Skript, weshalb `script-src 'none'` davon unberührt bleibt.
+
+Weiterleitungen werden vor dem 404 geprüft, aber **nach** der Seitensuche: eine vorhandene Seite gewinnt immer gegen eine gleichnamige Weiterleitung, sonst könnte ein alter Eintrag eine neue Seite unerreichbar machen. 301 braucht ein Ziel, 410 nicht. Ketten sind ausgeschlossen: zeigt das Ziel selbst weiter, wird der Eintrag abgewiesen statt einen zusätzlichen Umweg oder eine Schleife zu erzeugen.
+
+`robots.txt` erlaubt zusätzliche Zeilen aus den Einstellungen, aber nur Kommentare und einfache Anweisungen nach einem festen Muster. Eine zweite `Sitemap:`-Zeile oder Markup wird herausgefiltert, damit ein Eintrag in der Verwaltung die Datei nicht umdeuten kann.
+
+`llms.txt` ist **standardmäßig aus** und antwortet dann mit 404. Eingeschaltet listet es ausschließlich veröffentlichte, indexierbare Seiten und Beiträge mit Titel, Adresse und Auszug. Es ist kein Rankingfaktor und keine Voraussetzung für irgendein Suchprodukt; es erspart einem Agenten lediglich das Crawlen. Diese Einordnung steht auch in der Oberfläche.
+
+Der Brotkrumen-Block (Schema v9) trägt keine Inhalte: die Kette entsteht aus der gespeicherten Hierarchie, und nicht veröffentlichte Vorfahren fallen heraus. Eine Seite direkt unter der Wurzel gibt gar keine Navigation aus, statt eine einelementige Kette zu zeigen.
diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md
index d1eb1ff..50240fb 100644
--- a/docs/AUSBAUPLAN.md
+++ b/docs/AUSBAUPLAN.md
@@ -94,10 +94,12 @@ Formularbaukasten mit Feldtypen Text, E-Mail, Telefon, Auswahl, Mehrzeiliges, Ei
Abnahme: Das Kontaktformular des Piloten ist nachgebaut, funktioniert ohne JavaScript, weist ungültige Eingaben serverseitig ab und stellt eine E-Mail zu. Ein abgewiesener Versand verliert keine Eingaben.
-### E – SEO und maschinelle Zugänglichkeit
+### E – SEO und maschinelle Zugänglichkeit — **erledigt am 12.09.2026**
*Braucht: A und B für Sitemap und Breadcrumbs.*
+Umgesetzt: Indexierungssteuerung und Vorschaubild je Seite, Sitemap, robots.txt, Weiterleitungen mit 301 und 410, strukturierte Daten für Organization/LocalBusiness, Article und BreadcrumbList, Brotkrumen-Block und optionale llms.txt. **Offen geblieben:** getrennte SEO-Titel, Auflösung des Vorschaubilds zu `og:image`, Weiterleitungen mit Mustern, und automatische Weiterleitung beim Umbenennen (gehört zu Paket F). Details in ADR 018.
+
SEO-Felder je Seite: Titel, Beschreibung, Indexierungssteuerung, Open-Graph-Bild. Automatische `sitemap.xml`, `robots.txt`, Weiterleitungsverwaltung mit HTTP 301 und 410. Strukturierte Daten für `Organization`/`LocalBusiness` aus gepflegten Unternehmensdaten, für `Article` aus Beiträgen, für `BreadcrumbList` aus der Hierarchie. Breadcrumb-Block. `llms.txt` optional und abschaltbar, ohne jede Behauptung über Ranking oder Zitierung.
Abnahme: Sitemap und robots.txt stimmen mit dem veröffentlichten Bestand überein. Eine Adressänderung erzeugt eine Weiterleitung. Strukturierte Daten decken sich mit sichtbarem Inhalt und bestehen die Prüfung mit dem Testwerkzeug.
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index fa31d74..80f8994 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -1,5 +1,17 @@
# Qualitätsbericht – erster ausführbarer Prototyp
+## Ergänzung: SEO und maschinelle Zugänglichkeit, 12.09.2026
+
+- **128 PHP-Tests / 507 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**.
+- Sitemap geprüft: wohlgeformtes XML, enthält weder eine `noindex`-Seite noch einen unveröffentlichten Entwurf, dafür Startseite, indexierbare Seiten, Beitragsübersicht und Begriffsarchiv. `robots.txt` verweist auf die Sitemap und sperrt `/admin`. Dieselbe `noindex`-Seite trägt zusätzlich ein `noindex`-Meta, eine indexierbare Seite keines.
+- Weiterleitungen geprüft: 301 mit Zielangabe und 410 ohne, beide **vor** dem 404; eine vorhandene Seite gewinnt gegen eine gleichnamige Weiterleitung; Selbstbezug, Weiterleitungskette, Großbuchstaben im Pfad und ein anderer Statuscode als 301 oder 410 werden abgewiesen, ohne etwas zu schreiben.
+- Strukturierte Daten geprüft: ohne gepflegte Unternehmensdaten wird **kein** Block ausgegeben. Danach `LocalBusiness` mit Anschrift und Ländercode, doppelte Profile entdoppelt, und kein Feld ohne gepflegten Wert. Ein Beitrag trägt `Article` mit Überschrift und Autor aus seinen Metadaten, eine Unterseite `BreadcrumbList` mit absoluten Adressen. Die Brotkrumen erscheinen erst ab zwei Ebenen.
+- `llms.txt` antwortet ausgeschaltet mit HTTP 404 und listet eingeschaltet weder eine `noindex`-Seite noch einen Entwurf.
+- Fünf ungültige Unternehmensangaben (E-Mail, Telefon, Ländercode, unsicheres Profil, lokales Unternehmen ohne Anschrift) schreiben nichts. In `robots.txt` überleben weder Markup noch eine zweite `Sitemap:`-Zeile aus dem Freitextfeld.
+- Ein bestehender Flattermacher im Testabbau wurde behoben: unter Windows gab ein Kernel den Dateizeiger auf die Testdatenbank manchmal erst kurz nach dem Herunterfahren frei, wodurch das Aufräumen mit einer Warnung fehlschlug und den ganzen Lauf rot färbte. Der Abbau wartet jetzt kurz und lässt die temporäre Datei notfalls dem Betriebssystem.
+- **Erstmals seit Paket A wieder im Browser angesehen** – auf ausdrücklichen Wunsch, und ausdrücklich **keine** Messung: eine lokale Demoseite mit allen neuen Bausteinen wurde im Produktionsmodus aufgerufen und die Ausgabe geprüft. Kennzahlen, Symbolkarten mit Inline-SVG, Kundenstimme, Akkordeon, Kontaktdaten, Formular und Aufruf-Band erscheinen wie vorgesehen, die Tabs zeigen mit aktivem JavaScript nur den gewählten Abschnitt. **Das ist eine Sichtprüfung, kein Audit:** es liegen weiterhin keine axe- und keine Lighthouse-Ergebnisse für die Pakete B bis E vor, und die Bildschirmfotos des Vorschaufensters waren auf langen Seiten unzuverlässig, sodass Teile nur über die Auszeichnung und die gemessenen Elementhöhen beurteilt wurden.
+- Kosmetisch aufgefallen: eine Symbolkarte innerhalb einer Spalte zeigt zwei ineinanderliegende Rahmen, weil Spalte und Karte je einen eigenen Rahmen haben. Nicht behoben, weil es eine Gestaltungsentscheidung ist und keine Fehlfunktion.
+
## Ergänzung: Formulare, 12.09.2026
- **117 PHP-Tests / 450 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**. `composer audit` meldet keine bekannten Advisories, auch nach Aufnahme von `symfony/mailer`.
diff --git a/docs/STATUS.md b/docs/STATUS.md
index d7740c6..e5be100 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,6 +1,6 @@
# Projektstatus
-Stand: 12.09.2026. Arbeitstitel Kant CMS. **Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
+Stand: 12.09.2026. Arbeitstitel Kant CMS. **SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -25,6 +25,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.
+- SEO: Indexierungssteuerung und Vorschaubild je Seite, Unternehmensdaten unter `/admin/settings`, `sitemap.xml`, `robots.txt`, Weiterleitungen mit 301 und 410, optionale `llms.txt`, Brotkrumen-Block (Dokumentschema v9) sowie strukturierte Daten für `Organization`/`LocalBusiness`, `Article` und `BreadcrumbList`. Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet: eine nicht indexierbare Seite verschwindet gleichzeitig aus der Sitemap und bekommt ein `noindex`-Meta. Ohne gepflegte Unternehmensdaten wird kein leerer Organization-Block ausgegeben. `llms.txt` ist standardmäßig aus.
- Dokumentschema v8: Formulare. Verwaltung unter `/admin/forms` mit Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, Empfänger, Betreff, Antwortadresse und Erfolgsmeldung. **Der Formularblock im Seiteninhalt enthält nur die Adresse** – Felder und Empfänger liegen in der Datenbank, damit gespeicherter Inhalt nicht ändern kann, wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über `aria-describedby`/`aria-invalid`, Eingaben bleiben bei Ablehnung erhalten (HTTP 422), gültige Absendung per HTTP 303 zurück auf die Seite. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel und fünf Absendungen je Absender und Stunde. **Die Absenderadresse wird nie gespeichert**, nur ein Hash; der Zeitpunkt der Einwilligung getrennt. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert; nicht versendete Einsendungen sind in der Verwaltung gekennzeichnet. Voreingestellter Transport `sendmail`, per `MAILER_DSN` auf SMTP umstellbar. Kein CSRF-Token auf dem öffentlichen Weg, weil dafür eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite nötig wäre; Begründung in ADR 017.
- Dokumentschema v7: Tabs. Der Server liefert Ankerliste und alle Panels mit Überschriften, also vollständigen Inhalt ohne JavaScript; `public/assets/tabs.js` macht daraus ein echtes Tab-Widget mit `tablist`/`tab`/`tabpanel` und Pfeiltastenbedienung. **Die strenge Richtlinie bleibt Voreinstellung:** öffentliche Seiten senden weiterhin `script-src 'none'`, nur eine Seite mit Tab-Block sendet `script-src 'self'`. Inline-Skript ist nie erlaubt. In der Editorvorschau erscheinen Tabs untereinander, weil die Vorschau kein JavaScript ausführt; ein Hinweis im Eigenschaftenpanel sagt das.
- 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.
@@ -36,8 +37,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Geprüft
-- 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 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.
+- 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 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.
+- SEO geprüft: die Sitemap ist wohlgeformtes XML und enthält weder eine `noindex`-Seite noch einen Entwurf, dafür Beitragsübersicht und Begriffsarchiv; `robots.txt` verweist auf sie und sperrt `/admin`; eine nicht indexierbare Seite trägt zusätzlich ein `noindex`-Meta. Weiterleitungen antworten vor dem 404 mit 301 beziehungsweise 410, eine vorhandene Seite gewinnt gegen eine gleichnamige Weiterleitung, und Selbstbezug, Kette, Großbuchstaben sowie ein anderer Statuscode werden abgewiesen. Strukturierte Daten entstehen nur aus gepflegten Werten: ohne Angaben kein Block, danach `LocalBusiness` mit Anschrift und entdoppelten Profilen, `Article` mit Autor aus den Beitragsdaten und `BreadcrumbList` aus der Hierarchie. `llms.txt` antwortet ausgeschaltet mit 404 und listet eingeschaltet weder eine `noindex`-Seite noch einen Entwurf. Fünf ungültige Unternehmensangaben schreiben nichts, und in `robots.txt` überleben weder Markup noch eine zweite Sitemap-Zeile.
- Formulare geprüft: Ausgabe ohne Skript, Honeypot außerhalb der Tabreihenfolge, gültige Absendung wird gespeichert und per HTTP 303 zurückgeleitet, Einwilligungszeitpunkt festgehalten, **keine Absenderadresse in der Zeile** sondern ein 64-stelliger Hash, Erfolgsmeldung auf der Zielseite. Fehlerhafte Absendung: HTTP 422, nichts gespeichert, vier Meldungen an den richtigen Feldern mit `aria-describedby` und `aria-invalid`, genau eine Zusammenfassung mit `role=alert`, alle Eingaben erhalten. Vier Spamfälle (gefüllter Honeypot, zu schnell, gefälschte Signatur, zu alter Zeitstempel) werden still verworfen und wie ein Erfolg beantwortet. Das Limit stoppt die sechste Absendung innerhalb einer Stunde. Die Formularverwaltung verlangt Anmeldung, Editorrolle und CSRF-Token und weist eine ungültige Empfängeradresse mit Meldung ab.
- Tabs geprüft: beide Panels samt Inhalt und Überschrift stehen ohne JavaScript im HTML und sind serverseitig nicht versteckt, die Ankerliste verweist auf die Panels, das Skript wird als externe Datei geladen und nie inline. Die Richtlinie sendet `script-src 'self'` ausschließlich auf der Seite mit Tab-Block; eine Seite ohne Tabs und die 404-Seite behalten `script-src 'none'` und laden kein Skript. Ein Tab außerhalb einer Tabgruppe, eine Tabgruppe mit Textkind und Tabs in Schema v6 werden abgewiesen. Im Modell: eine neue Tabgruppe bringt zwei Tabs mit eigenen IDs mit, nimmt nur Tabs auf, und ein Tab lässt sich weder in eine Sektion noch in ein Akkordeon einfügen.
- 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.
@@ -64,6 +66,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.
+- SEO-Titel und -Beschreibung sind weiterhin die Dokumentfelder; getrennte SEO-Titel gibt es nicht. Das Vorschaubild wird über seine Mediathek-ID eingetragen und noch nicht als `og:image` aufgelöst. Weiterleitungen werden nur bei genauer Übereinstimmung des Pfades gefunden, ohne Muster und ohne Berücksichtigung von Abfrageparametern; Seiten umbenennen erzeugt noch keine automatische Weiterleitung (Paket F).
- **Dateiuploads in Formularen fehlen** – Anhänge brauchen einen nicht öffentlichen Ablageort mit Zugriffsschutz und eine eigene Typprüfung; Begründung in ADR 017. Ebenfalls offen: Formulare lassen sich nicht löschen oder umbenennen, Einsendungen nicht exportieren oder löschen, und es gibt keine Aufbewahrungsfrist. Der tatsächliche E-Mail-Versand ist nie ausgeführt worden: die Tests nutzen einen Transport, der nichts sendet.
- Das Tab-Skript ist nicht im Browser geprüft: Pfeiltastenbedienung, `aria-selected`, das Verstecken der Panels und die Auswahl über ein Sprungziel sind nur im Code umgesetzt, nicht ausgeführt. Geprüft ist bisher ausschließlich die serverseitig erzeugte Auszeichnung und die Sicherheitsrichtlinie.
- 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.
@@ -79,7 +82,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Nächster konkreter Schritt
-Arbeitspaket E aus `docs/AUSBAUPLAN.md`: SEO und maschinelle Zugänglichkeit – SEO-Felder je Seite, `sitemap.xml`, `robots.txt`, Weiterleitungen sowie strukturierte Daten für `LocalBusiness`, `Article` und `BreadcrumbList`.
+Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
diff --git a/migrations/Version20260912000800.php b/migrations/Version20260912000800.php
new file mode 100644
index 0000000..091fabb
--- /dev/null
+++ b/migrations/Version20260912000800.php
@@ -0,0 +1,47 @@
+getTable('page');
+ // 'index' or 'noindex'. A page excluded here also disappears from the sitemap.
+ $page->addColumn('robots', 'string', ['length' => 16, 'notnull' => true, 'default' => 'index']);
+ $page->addColumn('og_media_id', 'string', ['length' => 32, 'notnull' => false]);
+
+ // Company data, social profiles and switches, kept as one document per website.
+ $schema->getTable('website')->addColumn('settings', 'json', ['notnull' => false]);
+
+ $redirect = $schema->createTable('redirect');
+ $redirect->addColumn('id', 'string', ['length' => 64]);
+ $redirect->addColumn('website_id', 'string', ['length' => 64]);
+ // Stored without a leading slash, exactly like page.path, so both are comparable.
+ $redirect->addColumn('from_path', 'string', ['length' => 400]);
+ $redirect->addColumn('to_path', 'string', ['length' => 400, 'notnull' => false]);
+ // 301 for a moved address, 410 for one that is deliberately gone.
+ $redirect->addColumn('status', 'integer');
+ $redirect->addColumn('created_at', 'datetime_immutable');
+ $redirect->setPrimaryKey(['id']);
+ $redirect->addUniqueIndex(['website_id', 'from_path'], 'uniq_redirect_from');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->throwIrreversibleMigrationException('Contains indexing settings, company data and redirects. Restore a verified database backup with matching code.');
+ }
+}
diff --git a/public/assets/admin.css b/public/assets/admin.css
index f3e66ef..2b69995 100644
--- a/public/assets/admin.css
+++ b/public/assets/admin.css
@@ -20,3 +20,5 @@
@media (max-width:720px){.meta-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}}
.submission-card{align-items:flex-start}.submission-data{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:12px 0 0;font-size:12px}.submission-data dt{font-weight:600;color:var(--muted)}.submission-data dd{margin:0;overflow-wrap:anywhere}.menu-table .check-cell{text-align:center}.menu-table .check-cell input{width:18px;min-height:18px}
+
+.redirect-table{margin-top:26px}.redirect-table td{padding:8px 12px 8px 0;font-size:12px}.redirect-table form{margin:0}
diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js
index 542d3e5..1da91fd 100644
--- a/public/assets/editor-model.js
+++ b/public/assets/editor-model.js
@@ -1,5 +1,5 @@
-export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular' };
-export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉' };
+export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen' };
+export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩' };
// 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}$/;
@@ -57,6 +57,7 @@ export function insertBlock(document, type, selected) {
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
+ if (type === 'breadcrumb') upgradeDocument(document, 9);
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);
@@ -98,7 +99,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
- if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
+ if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -115,7 +116,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
- if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
+ if (![1, 2, 3, 4, 5, 6, 7, 8, 9].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/site.css b/public/assets/site.css
index 6b77572..783b3c1 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -92,3 +92,8 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.required-mark{color:#8b2f22}
/* Off screen rather than display:none so a filled value still proves automation. */
.form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
+
+.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
+.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
+.block-breadcrumb a{text-decoration:none}
+.block-breadcrumb a:hover{text-decoration:underline}
diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php
index 4a61438..381531d 100644
--- a/src/Content/DocumentValidator.php
+++ b/src/Content/DocumentValidator.php
@@ -15,11 +15,13 @@ final class DocumentValidator
public const V7_TYPES = ['tabs', 'tabitem'];
/** Schema v8: a form block references a form by its address; the definition lives in the database. */
public const V8_TYPES = ['form'];
+ /** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
+ public const V9_TYPES = ['breadcrumb'];
public function validate(array $document): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
- $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8], true), 'Unsupported document schema.');
+ $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9], 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.');
@@ -44,7 +46,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
- $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
+ $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || $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.');
diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php
index b1c1bce..e7fd82e 100644
--- a/src/Content/PageRepository.php
+++ b/src/Content/PageRepository.php
@@ -143,7 +143,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array
{
$page = $this->db->fetchAssociative(
- '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 = ?',
+ 'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, 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) {
@@ -157,7 +157,7 @@ final class PageRepository
public function listForEditor(): array
{
- return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
+ return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
}
public function findForEditor(string $id): ?array
diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php
index 4896f1d..a53af5f 100644
--- a/src/Content/PostRepository.php
+++ b/src/Content/PostRepository.php
@@ -40,18 +40,27 @@ final class PostRepository
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.');
+ $ogMediaId = trim((string) ($values['ogMediaId'] ?? ''));
+ foreach (['Beitragsbild' => $mediaId, 'Vorschaubild' => $ogMediaId] as $label => $candidate) {
+ if ('' !== $candidate && (1 !== preg_match('/^[a-f0-9]{32}$/D', $candidate) || null === $this->media->find($candidate))) {
+ throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
+ }
+ }
+ $robots = (string) ($values['robots'] ?? 'index');
+ if (!in_array($robots, ['index', 'noindex'], true)) {
+ throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.');
}
- $this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $categories, $tags, $expectedVersion): void {
+ $this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $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 = ?',
+ 'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, 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',
+ $robots,
+ '' === $ogMediaId ? null : $ogMediaId,
$pageId,
$expectedVersion,
],
diff --git a/src/Content/RedirectRepository.php b/src/Content/RedirectRepository.php
new file mode 100644
index 0000000..58aa7d0
--- /dev/null
+++ b/src/Content/RedirectRepository.php
@@ -0,0 +1,91 @@
+ */
+ public function all(): array
+ {
+ return array_map(static fn (array $row): array => [
+ 'id' => $row['id'], 'from' => $row['from_path'], 'to' => $row['to_path'], 'status' => (int) $row['status'],
+ ], $this->db->fetchAllAssociative('SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path', ['default']));
+ }
+
+ /** @return array{to: ?string, status: int}|null */
+ public function find(string $path): ?array
+ {
+ $row = $this->db->fetchAssociative('SELECT to_path, status FROM redirect WHERE website_id = ? AND from_path = ?', ['default', trim($path, '/')]);
+
+ return false === $row ? null : ['to' => $row['to_path'], 'status' => (int) $row['status']];
+ }
+
+ public function save(string $from, string $to, int $status): void
+ {
+ $from = trim(trim($from), '/');
+ $to = trim(trim($to), '/');
+ if (!in_array($status, [301, 410], true)) {
+ throw new InvalidPage('Erlaubt sind 301 für eine verschobene Adresse und 410 für eine entfernte.');
+ }
+ if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $from) || strlen($from) > 400) {
+ throw new InvalidPage('Die alte Adresse darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
+ }
+ if (301 === $status) {
+ if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $to) || strlen($to) > 400) {
+ throw new InvalidPage('Das Ziel darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
+ }
+ if ($from === $to) {
+ throw new InvalidPage('Eine Adresse kann nicht auf sich selbst weiterleiten.');
+ }
+ // One hop only: a chain would cost an extra round trip and can become a loop.
+ $next = $this->find($to);
+ if (null !== $next) {
+ throw new InvalidPage(sprintf('Das Ziel /%s leitet selbst weiter. Zeige direkt auf das Endziel.', $to));
+ }
+ }
+ if (self::MAX <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default'])
+ && null === $this->find($from)) {
+ throw new InvalidPage(sprintf('Mehr als %d Weiterleitungen sind nicht vorgesehen.', self::MAX));
+ }
+
+ $this->db->transactional(function () use ($from, $to, $status): void {
+ $this->db->executeStatement('DELETE FROM redirect WHERE website_id = ? AND from_path = ?', ['default', $from]);
+ $this->db->insert('redirect', [
+ 'id' => bin2hex(random_bytes(16)), 'website_id' => 'default',
+ 'from_path' => $from, 'to_path' => 301 === $status ? $to : null,
+ 'status' => $status, 'created_at' => gmdate('Y-m-d H:i:s'),
+ ]);
+ });
+ }
+
+ public function delete(string $id): void
+ {
+ $this->db->executeStatement('DELETE FROM redirect WHERE id = ? AND website_id = ?', [$id, 'default']);
+ }
+
+ /** Records that an address moved, used when a page is renamed. Never overwrites an entry. */
+ public function remember(string $from, string $to): void
+ {
+ if ($from === $to || null !== $this->find($from)) {
+ return;
+ }
+ try {
+ $this->save($from, $to, 301);
+ } catch (InvalidPage) {
+ // A path the rules reject is simply not remembered; renaming must not fail for it.
+ }
+ }
+}
diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php
new file mode 100644
index 0000000..d7d524a
--- /dev/null
+++ b/src/Content/SiteSettings.php
@@ -0,0 +1,127 @@
+ '',
+ 'description' => '',
+ 'phone' => '',
+ 'email' => '',
+ 'street' => '',
+ 'postalCode' => '',
+ 'city' => '',
+ 'country' => 'DE',
+ 'sameAs' => [],
+ 'localBusiness' => false,
+ 'llmsTxt' => false,
+ 'robotsExtra' => '',
+ ];
+
+ public function __construct(private Connection $db)
+ {
+ }
+
+ public function load(): array
+ {
+ $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
+ $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
+
+ return [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
+ }
+
+ public function save(array $input): void
+ {
+ $values = self::DEFAULTS;
+ foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
+ $values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
+ }
+ if ('' !== $values['email'] && false === filter_var($values['email'], FILTER_VALIDATE_EMAIL)) {
+ throw new InvalidPage('Bitte gib eine gültige E-Mail-Adresse an.');
+ }
+ if ('' !== $values['phone'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $values['phone'])) {
+ throw new InvalidPage('Bitte gib eine gültige Telefonnummer an.');
+ }
+ $country = strtoupper(trim((string) ($input['country'] ?? 'DE')));
+ if (1 !== preg_match('/^[A-Z]{2}$/D', $country)) {
+ throw new InvalidPage('Das Land wird als zweibuchstabiger Code angegeben, zum Beispiel DE.');
+ }
+ $values['country'] = $country;
+ foreach (preg_split('/[\r\n,]+/', (string) ($input['sameAs'] ?? '')) ?: [] as $profile) {
+ $profile = trim($profile);
+ if ('' === $profile) {
+ continue;
+ }
+ if (!str_starts_with($profile, 'https://') || false === filter_var($profile, FILTER_VALIDATE_URL)) {
+ throw new InvalidPage(sprintf('„%s“ ist keine HTTPS-Adresse.', $profile));
+ }
+ $values['sameAs'][] = $profile;
+ }
+ $values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
+ $values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
+ if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
+ throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
+ }
+ $values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
+ // Only comment and simple directive lines; nothing that could smuggle in a second policy.
+ $extra = [];
+ foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
+ $line = trim($line);
+ if ('' !== $line && 1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
+ $extra[] = $line;
+ }
+ }
+ $values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30));
+
+ $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
+ json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+ 'default',
+ ]);
+ }
+
+ /**
+ * Structured data for the organisation, built from the maintained values only. Returns null
+ * while nothing is maintained: an empty Organization block would be noise, not data.
+ */
+ public function organisation(array $settings, string $origin, string $siteName): ?array
+ {
+ $hasAddress = '' !== $settings['street'] && '' !== $settings['city'];
+ if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
+ return null;
+ }
+ $data = [
+ '@context' => 'https://schema.org',
+ '@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
+ 'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
+ 'url' => $origin.'/',
+ ];
+ foreach (['description' => 'description', 'phone' => 'telephone', 'email' => 'email'] as $key => $property) {
+ if ('' !== $settings[$key]) {
+ $data[$property] = $settings[$key];
+ }
+ }
+ if ($hasAddress) {
+ $data['address'] = array_filter([
+ '@type' => 'PostalAddress',
+ 'streetAddress' => $settings['street'],
+ 'postalCode' => $settings['postalCode'],
+ 'addressLocality' => $settings['city'],
+ 'addressCountry' => $settings['country'],
+ ], static fn (string $value): bool => '' !== $value);
+ }
+ if ([] !== $settings['sameAs']) {
+ $data['sameAs'] = $settings['sameAs'];
+ }
+
+ return $data;
+ }
+}
diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php
index 94a3764..f7132be 100644
--- a/src/Controller/EditorController.php
+++ b/src/Controller/EditorController.php
@@ -31,9 +31,7 @@ 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']);
- }
+ $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
}
unset($page);
@@ -88,6 +86,8 @@ final class EditorController extends AbstractController
'author' => $request->request->getString('author'),
'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'),
+ 'ogMediaId' => $request->request->getString('ogMediaId'),
+ 'robots' => $request->request->getString('robots', 'index'),
],
$terms($request->request->getString('categories')),
$terms($request->request->getString('tags')),
diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php
index d9fe990..13f05e3 100644
--- a/src/Controller/PageController.php
+++ b/src/Controller/PageController.php
@@ -6,6 +6,8 @@ use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
+use App\Content\RedirectRepository;
+use App\Content\SiteSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -13,13 +15,13 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
- public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private string $secret, private string $siteOrigin)
+ public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private string $secret, 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/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$)[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$|sitemap\.xml$|robots\.txt$|llms\.txt$)[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'])]
@@ -31,7 +33,12 @@ final class PageController extends AbstractController
}
$page = $this->pages->findPublished($path);
if (null === $page) {
- $response = $this->render('not-found.html.twig', [], new Response(status: 404));
+ // An address that moved or was deliberately removed answers before the 404 does.
+ $redirect = $this->redirects->find($path);
+ if (null !== $redirect && 301 === $redirect['status']) {
+ return $this->shell($this->redirect('/'.$redirect['to'], 301));
+ }
+ $response = $this->render('not-found.html.twig', [], new Response(status: null !== $redirect ? 410 : 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
@@ -41,6 +48,8 @@ final class PageController extends AbstractController
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'needsTabs' => $this->hasType($page['document'], 'tabs'),
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
+ 'breadcrumbs' => $this->breadcrumbs($page),
+ 'structuredData' => $this->structuredData($page),
]);
}
@@ -96,6 +105,79 @@ final class PageController extends AbstractController
return $states;
}
+ /**
+ * The trail from the home page down to this one, resolved from the stored hierarchy rather
+ * than from content, so it cannot claim a page that does not exist.
+ *
+ * @return list
+ */
+ private function breadcrumbs(array $page): array
+ {
+ if ('home' === $page['path']) {
+ return [];
+ }
+ $trail = [];
+ $prefix = '';
+ foreach (explode('/', (string) $page['path']) as $segment) {
+ $prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
+ $ancestor = $this->pages->findPublished($prefix);
+ if (null !== $ancestor) {
+ $trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
+ }
+ }
+
+ return $trail;
+ }
+
+ /**
+ * JSON-LD built from values that are visible on the page or maintained in the settings.
+ * Nothing is invented here; a field without a maintained value is left out.
+ *
+ * @return list
+ */
+ private function structuredData(array $page): array
+ {
+ $origin = rtrim($this->siteOrigin, '/');
+ $blocks = [];
+ if ('home' === $page['path']) {
+ $organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']);
+ if (null !== $organisation) {
+ $blocks[] = $organisation;
+ }
+ }
+ if ('post' === $page['type']) {
+ $article = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ 'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
+ 'mainEntityOfPage' => $origin.'/'.$page['path'],
+ ];
+ foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
+ if ('' !== (string) $value && null !== $value) {
+ $article[$key] = 'datePublished' === $key ? str_replace(' ', 'T', (string) $value).'Z' : (string) $value;
+ }
+ }
+ if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
+ $article['author'] = ['@type' => 'Person', 'name' => $page['author']];
+ }
+ $blocks[] = $article;
+ }
+ $trail = $this->breadcrumbs($page);
+ if (count($trail) > 1) {
+ $blocks[] = [
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => array_map(
+ static fn (int $index, array $item): array => ['@type' => 'ListItem', 'position' => $index + 1, 'name' => $item['name'], 'item' => $origin.$item['href']],
+ array_keys($trail),
+ $trail,
+ ),
+ ];
+ }
+
+ return $blocks;
+ }
+
/** True when the stored document contains at least one block of that type. */
private function hasType(array $document, string $type): bool
{
diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php
new file mode 100644
index 0000000..af11320
--- /dev/null
+++ b/src/Controller/SeoController.php
@@ -0,0 +1,126 @@
+siteOrigin, '/');
+ $entries = [];
+ // Only published pages that are allowed to be indexed. A draft or a noindex page in the
+ // sitemap would ask a search engine to fetch something it must not list.
+ foreach ($this->db->fetchAllAssociative(
+ "SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' ORDER BY p.path",
+ ['default'],
+ ) as $row) {
+ $entries[] = [
+ 'loc' => $origin.('home' === $row['path'] ? '/' : '/'.$row['path']),
+ 'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
+ ];
+ }
+ // Archives exist only while they list something.
+ if ([] !== $this->posts->published(1, null, 1)['posts']) {
+ $entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
+ }
+ foreach ($this->db->fetchAllAssociative(
+ 'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL ORDER BY t.kind, t.slug',
+ ) as $row) {
+ $entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
+ }
+
+ return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
+ }
+
+ #[Route('/robots.txt', name: 'robots', methods: ['GET'])]
+ public function robots(): Response
+ {
+ $settings = $this->settings->load();
+ $lines = ['User-agent: *', 'Disallow: /admin', 'Disallow: /formular/'];
+ if ('' !== $settings['robotsExtra']) {
+ $lines[] = $settings['robotsExtra'];
+ }
+ $lines[] = '';
+ $lines[] = 'Sitemap: '.rtrim($this->siteOrigin, '/').'/sitemap.xml';
+
+ return $this->text(implode("\n", $lines)."\n");
+ }
+
+ /**
+ * A machine readable summary of public content. Optional and switched off by default. It is
+ * no ranking factor and no requirement for any search product; it simply saves an agent the
+ * crawl. Only published, indexable pages appear.
+ */
+ #[Route('/llms.txt', name: 'llms', methods: ['GET'])]
+ public function llms(): Response
+ {
+ $settings = $this->settings->load();
+ if (!$settings['llmsTxt']) {
+ throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.');
+ }
+ $origin = rtrim($this->siteOrigin, '/');
+ $name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']);
+ $lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), ''];
+ if ('' !== $settings['description']) {
+ $lines[] = '> '.$settings['description'];
+ $lines[] = '';
+ }
+ foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
+ $rows = $this->db->fetchAllAssociative(
+ "SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.type = ? ORDER BY p.path",
+ ['default', $type],
+ );
+ if ([] === $rows) {
+ continue;
+ }
+ $lines[] = $heading;
+ foreach ($rows as $row) {
+ $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
+ $summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? ''));
+ $lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), '' === $summary ? '' : ': '.$summary);
+ }
+ $lines[] = '';
+ }
+
+ return $this->text(implode("\n", $lines));
+ }
+
+ private function text(string $body): Response
+ {
+ return $this->harden(new Response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']));
+ }
+
+ private function xml(string $body): Response
+ {
+ return $this->harden(new Response($body, 200, ['Content-Type' => 'application/xml; charset=UTF-8']));
+ }
+
+ private function harden(Response $response): Response
+ {
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
+ $response->headers->set('Cache-Control', 'no-store');
+
+ return $response;
+ }
+}
diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php
new file mode 100644
index 0000000..ef992a6
--- /dev/null
+++ b/src/Controller/SettingsController.php
@@ -0,0 +1,76 @@
+render('admin/settings.html.twig', [
+ 'settings' => $this->settings->load(),
+ 'redirects' => $this->redirects->all(),
+ ]);
+ }
+
+ #[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
+ public function save(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('ROLE_EDITOR');
+ if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
+ return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
+ }
+ try {
+ $this->settings->save($request->request->all());
+ } catch (InvalidPage $e) {
+ return $this->back($e->getMessage());
+ }
+
+ return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
+ }
+
+ #[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
+ public function saveRedirect(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('ROLE_EDITOR');
+ if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
+ return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
+ }
+ $remove = $request->request->getString('remove');
+ if ('' !== $remove) {
+ $this->redirects->delete($remove);
+
+ return $this->back('Die Weiterleitung wurde entfernt.', 'success');
+ }
+ try {
+ $this->redirects->save(
+ $request->request->getString('from'),
+ $request->request->getString('to'),
+ $request->request->getInt('status', 301),
+ );
+ } catch (InvalidPage $e) {
+ return $this->back($e->getMessage());
+ }
+
+ return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
+ }
+
+ private function back(string $message, string $type = 'error'): Response
+ {
+ $this->addFlash($type, $message);
+
+ return $this->redirectToRoute('admin_settings');
+ }
+}
diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig
index c837102..a796c42 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.
Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.
Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.
Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.
+
+ {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
+
{{ message }}
+ {% endfor %}{% endfor %}
+
+
+
Unternehmensdaten
Grundlage für Organization und LocalBusiness
+
+
+
+
+
Weiterleitungen {{ redirects|length }}
Werden vor dem 404 geprüft.
+
+
+ {% if redirects is not empty %}
+
+
Gespeicherte Weiterleitungen
+
Alte Adresse
Ziel
Art
Entfernen
+
+ {% for entry in redirects %}
+
+
/{{ entry.from }}
+
{% if entry.to %}/{{ entry.to }}{% else %}—{% endif %}
+
{{ entry.status }}
+
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
Kant CMS · Visueller Editor, erster Prototyp
+
+{% endblock %}
diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig
index e3e8eea..db2a270 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), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% if type == 'section' %}
{% endif %}
{{ tag }}>
{% elseif type == 'heading' %}
@@ -39,7 +39,7 @@
{% elseif type == 'logos' %}
- {% for child in block.children|default([]) %}
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}
{% endfor %}
+ {% for child in block.children|default([]) %}
{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}
{% endfor %}
{% elseif type == 'cta' %}
@@ -59,6 +59,17 @@
{% elseif type == 'spacer' %}
+{% elseif type == 'breadcrumb' %}
+ {% if breadcrumbs|default([])|length > 1 %}
+
+ {% endif %}
{% elseif type == 'form' %}
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
@@ -70,22 +81,22 @@
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% elseif type == 'tabitem' %}
{{ props.title }}
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% elseif type == 'accordion' %}
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} 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({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% elseif type == 'postlist' %}
@@ -94,7 +105,7 @@
{% elseif type == 'gallery' %}
- {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}
{% else %}
{#
diff --git a/templates/page.html.twig b/templates/page.html.twig
index fad1e1e..8b742c6 100644
--- a/templates/page.html.twig
+++ b/templates/page.html.twig
@@ -6,12 +6,21 @@
{{ page.document.title }}
+ {% if page.robots|default('index') != 'index' %}{% endif %}
+
+
+
+
+ {% if socialImage|default(null) %}{% endif %}
{% if page.document.schemaVersion >= 2 %}{% endif %}
{% if page.document.schemaVersion >= 3 %}{% endif %}
{% if needsTabs|default(false) %}{% endif %}
+ {# A data block, not a script: CSP script-src does not apply to application/ld+json. #}
+ {% for block in structuredData|default([]) %}
+ {% endfor %}
Zum Inhalt springen
@@ -30,7 +39,7 @@
{% for term in postMeta.tag %}#{{ term.name }}{% endfor %}
{% endif %}
- {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
+ {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}{% endfor %}