SEO, Weiterleitungen und strukturierte Daten (Arbeitspaket E)

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 separat gepflegt: die Sitemap listet nur
veröffentlichte, indexierbare Seiten samt Beitragsübersicht und
Begriffsarchiven, und dieselbe Einstellung setzt auch das
noindex-Meta - beide Aussagen können nicht auseinanderlaufen.

Strukturierte Daten entstehen nur aus gepflegten Werten: ohne
Angaben wird kein leerer Organization-Block ausgegeben. JSON-LD ist
ein Datenblock, kein Skript, daher bleibt script-src 'none'
unberührt.

Weiterleitungen greifen vor dem 404, aber nach der Seitensuche: eine
vorhandene Seite gewinnt immer, sonst könnte ein alter Eintrag eine
neue Seite unerreichbar machen. Ketten werden abgewiesen. robots.txt
nimmt zusätzliche Zeilen nur nach festem Muster an, damit ein
Eintrag die Datei nicht umdeuten kann. llms.txt ist standardmäßig
aus.

Geprüft: 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22,
14 JavaScript-Modelltests, Lint/Format/Container. Dazu eine reine
Sichtprüfung im Browser auf einer lokalen Demoseite - weiterhin
keine axe- oder Lighthouse-Messung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 12:43:00 +02:00
1 parent d43bd91069
commit a40c2bb27e
30 files changed
+1012 -38

No files matched your search

+11
View File
@@ -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
}
]
}
+3
View File
@@ -19,3 +19,6 @@ services:
arguments: arguments:
$secret: '%kernel.secret%' $secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%' $siteOrigin: '%env(SITE_ORIGIN)%'
App\Controller\SeoController:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
+16
View File
@@ -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. **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. 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.
+3 -1
View File
@@ -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. 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.* *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. 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. 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.
+12
View File
@@ -1,5 +1,17 @@
# Qualitätsbericht – erster ausführbarer Prototyp # 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 ## 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`. - **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`.
+6 -3
View File
@@ -1,6 +1,6 @@
# Projektstatus # 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. 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. - 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. - 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. - 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 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 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. - 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 ## 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - **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. - 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. - 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 ## 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. `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.
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260912000800 extends AbstractMigration
{
public function getDescription(): string
{
return 'Indexing control, social image, company data and redirects.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
$page = $schema->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.');
}
}
+2
View File
@@ -20,3 +20,5 @@
@media (max-width:720px){.meta-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}} @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} .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}
+5 -4
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular' }; 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: '✉' }; 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 // 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. // 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 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 (['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 === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8); if (type === 'form') upgradeDocument(document, 8);
if (type === 'breadcrumb') upgradeDocument(document, 9);
let target = locate(document.blocks, selected); 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?.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); 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. // Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) { 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); document.schemaVersion = Math.max(document.schemaVersion, version);
return document; return document;
} }
@@ -115,7 +116,7 @@ export function effectiveDesign(block, device, key) {
} }
export function setDesign(document, id, device, key, value) { 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.'); if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block; const block = locate(document.blocks, id)?.block;
if (!block) return; if (!block) return;
+5
View File
@@ -92,3 +92,8 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.required-mark{color:#8b2f22} .required-mark{color:#8b2f22}
/* Off screen rather than display:none so a filled value still proves automation. */ /* 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} .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}
+4 -2
View File
@@ -15,11 +15,13 @@ final class DocumentValidator
public const V7_TYPES = ['tabs', 'tabitem']; public const V7_TYPES = ['tabs', 'tabitem'];
/** Schema v8: a form block references a form by its address; the definition lives in the database. */ /** Schema v8: a form block references a form by its address; the definition lives in the database. */
public const V8_TYPES = ['form']; 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 public function validate(array $document): void
{ {
$schemaVersion = $document['schemaVersion'] ?? null; $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['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320); $this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); $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 // 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. // 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); $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 // 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. // else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
+2 -2
View File
@@ -143,7 +143,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array public function findPublished(string $path, string $locale = 'de'): ?array
{ {
$page = $this->db->fetchAssociative( $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'], [$path, $locale, 'default'],
); );
if (false === $page) { if (false === $page) {
@@ -157,7 +157,7 @@ final class PageRepository
public function listForEditor(): array 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 public function findForEditor(string $id): ?array
+13 -4
View File
@@ -40,18 +40,27 @@ final class PostRepository
throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.'); throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.');
} }
$mediaId = trim((string) ($values['featuredMediaId'] ?? '')); $mediaId = trim((string) ($values['featuredMediaId'] ?? ''));
if ('' !== $mediaId && (1 !== preg_match('/^[a-f0-9]{32}$/D', $mediaId) || null === $this->media->find($mediaId))) { $ogMediaId = trim((string) ($values['ogMediaId'] ?? ''));
throw new InvalidPage('Das gewählte Beitragsbild gibt es nicht.'); 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( $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, '' === $excerpt ? null : $excerpt,
'' === $author ? null : $author, '' === $author ? null : $author,
'' === $mediaId ? null : $mediaId, '' === $mediaId ? null : $mediaId,
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00', '' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
$robots,
'' === $ogMediaId ? null : $ogMediaId,
$pageId, $pageId,
$expectedVersion, $expectedVersion,
], ],
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Addresses that moved or are deliberately gone. Checked before a page is answered with 404, so a
* migrated site keeps every old link working.
*/
final class RedirectRepository
{
public const MAX = 2000;
public function __construct(private Connection $db)
{
}
/** @return list<array{id: string, from: string, to: ?string, status: int}> */
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.
}
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Company data and site wide switches. The structured data the pages emit is built from exactly
* these values, so what a search engine reads is what an editor maintains here – never a separate,
* invisible copy.
*/
final class SiteSettings
{
public const DEFAULTS = [
'legalName' => '',
'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;
}
}
+3 -3
View File
@@ -31,9 +31,7 @@ final class EditorController extends AbstractController
foreach ($pages as &$page) { foreach ($pages as &$page) {
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug']; $page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/'); $page['depth'] = substr_count((string) $page['path'], '/');
if ('post' === $page['type']) { $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
$page['terms'] = $this->posts->namesFor($page['id']);
}
} }
unset($page); unset($page);
@@ -88,6 +86,8 @@ final class EditorController extends AbstractController
'author' => $request->request->getString('author'), 'author' => $request->request->getString('author'),
'publishedAt' => $request->request->getString('publishedAt'), 'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'), '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('categories')),
$terms($request->request->getString('tags')), $terms($request->request->getString('tags')),
+85 -3
View File
@@ -6,6 +6,8 @@ use App\Content\FormRepository;
use App\Content\MenuRepository; use App\Content\MenuRepository;
use App\Content\PageRepository; use App\Content\PageRepository;
use App\Content\PostRepository; use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -13,13 +15,13 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController 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 // 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. // 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('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], 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); $page = $this->pages->findPublished($path);
if (null === $page) { 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 { } else {
$response = $this->render('page.html.twig', [ $response = $this->render('page.html.twig', [
'page' => $page, 'page' => $page,
@@ -41,6 +48,8 @@ final class PageController extends AbstractController
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'needsTabs' => $this->hasType($page['document'], 'tabs'), 'needsTabs' => $this->hasType($page['document'], 'tabs'),
'forms' => $this->formStates($page, $request->query->getString('gesendet')), '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; 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<array{name: string, href: string}>
*/
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<array>
*/
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. */ /** True when the stored document contains at least one block of that type. */
private function hasType(array $document, string $type): bool private function hasType(array $document, string $type): bool
{ {
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Controller;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The machine readable surface of a site: sitemap, robots and the optional llms.txt. Everything
* here is derived from published content, never maintained separately, so it cannot drift apart
* from what a visitor actually sees.
*/
final class SeoController extends AbstractController
{
public function __construct(
private Connection $db,
private SiteSettings $settings,
private PostRepository $posts,
private string $siteOrigin,
) {
}
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
public function sitemap(): Response
{
$origin = rtrim($this->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;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Controller;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects)
{
}
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
public function index(): Response
{
return $this->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');
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3> <section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p> <p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
<div id="library" class="library"> <div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %} {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
</div> </div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button> <button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p> <h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1"> <main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">FORMULARE</p><h1>Formulare</h1> <p class="eyebrow">FORMULARE</p><h1>Formulare</h1>
<p class="muted">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.</p> <p class="muted">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.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a></p> <p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %} {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p> <p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1"> <main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">NAVIGATION</p><h1>Menüs</h1> <p class="eyebrow">NAVIGATION</p><h1>Menüs</h1>
<p class="muted">Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.</p> <p class="muted">Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a></p> <p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %} {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p> <p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
+14 -4
View File
@@ -8,7 +8,7 @@
<main id="admin-main" class="pages-main" tabindex="-1"> <main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1> <p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p> <p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p> <p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="{{ path('admin_settings') }}">Einstellungen</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %} {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p> <p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
@@ -63,12 +63,21 @@
</div> </div>
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span> <span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span>
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a> <a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
{% if page.type == 'post' and is_granted('ROLE_EDITOR') %} {% if is_granted('ROLE_EDITOR') %}
<details class="meta-panel"> <details class="meta-panel">
<summary aria-label="Beitragsdaten von {{ page.title }}">Beitragsdaten</summary> <summary aria-label="Seitendaten von {{ page.title }}">{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</summary>
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post"> <form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}"> <input type="hidden" name="expectedVersion" value="{{ page.version }}">
<label for="robots-{{ page.id }}">Suchmaschinen</label>
<select id="robots-{{ page.id }}" name="robots">
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
</select>
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
{% if page.type == 'post' %}
<label for="date-{{ page.id }}">Veröffentlicht am</label> <label for="date-{{ page.id }}">Veröffentlicht am</label>
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}"> <input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
<label for="author-{{ page.id }}">Autor</label> <label for="author-{{ page.id }}">Autor</label>
@@ -83,7 +92,8 @@
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p> <p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
<label for="media-{{ page.id }}">Beitragsbild</label> <label for="media-{{ page.id }}">Beitragsbild</label>
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek"> <input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
<button class="primary" type="submit">Beitragsdaten speichern</button> {% endif %}
<button class="primary" type="submit">Speichern</button>
</form> </form>
</details> </details>
{% endif %} {% endif %}
+124
View File
@@ -0,0 +1,124 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Einstellungen{% endblock %}
{% block body %}
<header class="admin-header">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
</header>
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">EINSTELLUNGEN</p><h1>Unternehmen und Adressen</h1>
<p class="muted">Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.</p>
<p class="admin-links">
<a class="secondary" href="{{ path('admin_pages') }}">Seiten</a>
<a class="secondary" href="{{ path('admin_menus') }}">Menüs</a>
<a class="secondary" href="{{ path('admin_forms') }}">Formulare</a>
<a class="secondary" href="/sitemap.xml">Sitemap ↗</a>
<a class="secondary" href="/robots.txt">robots.txt ↗</a>
</p>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
{% endfor %}{% endfor %}
<section class="menu-section">
<div class="section-heading"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
<form action="{{ path('admin_settings_save') }}" method="post" class="menu-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<div class="create-grid">
<div>
<label for="legalName">Name des Unternehmens</label>
<input id="legalName" name="legalName" maxlength="200" value="{{ settings.legalName }}">
</div>
<div>
<label for="phone">Telefon</label>
<input id="phone" name="phone" maxlength="40" value="{{ settings.phone }}">
</div>
<div>
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" maxlength="120" value="{{ settings.email }}">
</div>
<div>
<label for="street">Straße und Hausnummer</label>
<input id="street" name="street" maxlength="120" value="{{ settings.street }}">
</div>
<div>
<label for="postalCode">Postleitzahl</label>
<input id="postalCode" name="postalCode" maxlength="16" value="{{ settings.postalCode }}">
</div>
<div>
<label for="city">Ort</label>
<input id="city" name="city" maxlength="120" value="{{ settings.city }}">
</div>
<div>
<label for="country">Land</label>
<input id="country" name="country" maxlength="2" pattern="[A-Za-z]{2}" value="{{ settings.country }}">
<p class="field-help">Zweibuchstabiger Code, zum Beispiel DE.</p>
</div>
</div>
<label for="description">Kurzbeschreibung</label>
<input id="description" name="description" maxlength="300" value="{{ settings.description }}">
<label for="sameAs">Profile</label>
<textarea id="sameAs" name="sameAs" rows="3" placeholder="https://www.linkedin.com/company/…">{{ settings.sameAs|join('\n') }}</textarea>
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig.</p>
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
<textarea id="robotsExtra" name="robotsExtra" rows="3">{{ settings.robotsExtra }}</textarea>
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>. Höchstens dreißig Zeilen.</p>
<button class="primary" type="submit">Unternehmensdaten speichern</button>
</form>
</section>
<section class="menu-section">
<div class="section-heading"><h2>Weiterleitungen <span class="count">{{ redirects|length }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
<form action="{{ path('admin_redirect_save') }}" method="post" class="menu-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<div class="create-grid">
<div>
<label for="from">Alte Adresse</label>
<input id="from" name="from" maxlength="400" required placeholder="alte-seite">
</div>
<div>
<label for="to">Neue Adresse</label>
<input id="to" name="to" maxlength="400" placeholder="leistungen/webdesign">
<p class="field-help">Bei 410 leer lassen.</p>
</div>
<div>
<label for="status">Art</label>
<select id="status" name="status">
<option value="301">301 – dauerhaft verschoben</option>
<option value="410">410 – entfernt</option>
</select>
</div>
</div>
<button class="primary" type="submit">Weiterleitung speichern</button>
</form>
{% if redirects is not empty %}
<table class="menu-table redirect-table">
<caption class="visually-hidden">Gespeicherte Weiterleitungen</caption>
<thead><tr><th scope="col">Alte Adresse</th><th scope="col">Ziel</th><th scope="col">Art</th><th scope="col">Entfernen</th></tr></thead>
<tbody>
{% for entry in redirects %}
<tr>
<td><code>/{{ entry.from }}</code></td>
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{{ entry.status }}</td>
<td>
<form action="{{ path('admin_redirect_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="remove" value="{{ entry.id }}">
<button class="text-button danger-text" type="submit" aria-label="Weiterleitung von /{{ entry.from }} entfernen">Entfernen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
</main>
{% endblock %}
+18 -7
View File
@@ -10,7 +10,7 @@
{% set tag = type == 'section' ? 'section' : 'div' %} {% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}"> <{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %} {% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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' %}</div>{% endif %} {% if type == 'section' %}</div>{% endif %}
</{{ tag }}> </{{ tag }}>
{% elseif type == 'heading' %} {% elseif type == 'heading' %}
@@ -39,7 +39,7 @@
</figure> </figure>
{% elseif type == 'logos' %} {% elseif type == 'logos' %}
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}"> <ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}</li>{% endfor %} {% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([])} only %}</li>{% endfor %}
</ul> </ul>
{% elseif type == 'cta' %} {% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}"> <div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -59,6 +59,17 @@
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}"> <hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
{% elseif type == 'spacer' %} {% elseif type == 'spacer' %}
<div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div> <div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div>
{% elseif type == 'breadcrumb' %}
{% if breadcrumbs|default([])|length > 1 %}
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
<ol>
<li><a href="/">Start</a></li>
{% for crumb in breadcrumbs %}
<li>{% if loop.last %}<span aria-current="page">{{ crumb.name }}</span>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %}</li>
{% endfor %}
</ol>
</nav>
{% endif %}
{% elseif type == 'form' %} {% elseif type == 'form' %}
<div id="{{ block.id }}" class="block-formwrap{{ designClasses }}"> <div id="{{ block.id }}" class="block-formwrap{{ designClasses }}">
{% 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 %} {% 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 @@
<ul class="tabs-nav"> <ul class="tabs-nav">
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %} {% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
</ul> </ul>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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 %}
</div> </div>
{% elseif type == 'tabitem' %} {% elseif type == 'tabitem' %}
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}"> <section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
<h3 class="tabs-heading">{{ props.title }}</h3> <h3 class="tabs-heading">{{ props.title }}</h3>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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 %}
</section> </section>
{% elseif type == 'accordion' %} {% elseif type == 'accordion' %}
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}"> <div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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 %}
</div> </div>
{% elseif type == 'accordionitem' %} {% elseif type == 'accordionitem' %}
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}> <details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
<summary>{{ props.title }}</summary> <summary>{{ props.title }}</summary>
<div class="accordion-body"> <div class="accordion-body">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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 %}
</div> </div>
</details> </details>
{% elseif type == 'postlist' %} {% elseif type == 'postlist' %}
@@ -94,7 +105,7 @@
</div> </div>
{% elseif type == 'gallery' %} {% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"> <div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} 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 %}
</div> </div>
{% else %} {% else %}
{# {#
+10 -1
View File
@@ -6,12 +6,21 @@
<title>{{ page.document.title }}</title> <title>{{ page.document.title }}</title>
<meta name="description" content="{{ page.document.description }}"> <meta name="description" content="{{ page.document.description }}">
<link rel="canonical" href="{{ canonical }}"> <link rel="canonical" href="{{ canonical }}">
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
<meta property="og:title" content="{{ page.document.title }}">
<meta property="og:description" content="{{ page.document.description }}">
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
<meta property="og:url" content="{{ canonical }}">
{% if socialImage|default(null) %}<meta property="og:image" content="{{ socialImage }}"><meta name="twitter:card" content="summary_large_image">{% endif %}
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css"> <link rel="stylesheet" href="/assets/site.css">
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml"> <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %} {% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %} {% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js" defer></script>{% endif %} {% if needsTabs|default(false) %}<script src="/assets/tabs.js" defer></script>{% endif %}
{# A data block, not a script: CSP script-src does not apply to application/ld+json. #}
{% for block in structuredData|default([]) %}<script type="application/ld+json">{{ block|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
{% endfor %}
</head> </head>
<body> <body>
<a class="skip-link" href="#main">Zum Inhalt springen</a> <a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -30,7 +39,7 @@
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %} {% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p> </p>
{% endif %} {% 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 %}
</main> </main>
<footer class="site-footer shell"> <footer class="site-footer shell">
<a class="brand" href="/">kant<span class="brand-light">cms</span></a> <a class="brand" href="/">kant<span class="brand-light">cms</span></a>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for entry in entries %}
<url>
<loc>{{ entry.loc }}</loc>
{% if entry.lastmod %} <lastmod>{{ entry.lastmod }}</lastmod>
{% endif %} </url>
{% endfor %}
</urlset>
+193 -1
View File
@@ -8,6 +8,8 @@ use App\Content\MediaLibrary;
use App\Content\MenuRepository; use App\Content\MenuRepository;
use App\Content\PageRepository; use App\Content\PageRepository;
use App\Content\PostRepository; use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidDocument; use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu; use App\Domain\InvalidMenu;
use App\Domain\InvalidPage; use App\Domain\InvalidPage;
@@ -22,6 +24,7 @@ use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500; use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600; use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700; use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
@@ -34,6 +37,8 @@ final class CmsTest extends TestCase
private PageRepository $pages; private PageRepository $pages;
private MenuRepository $menus; private MenuRepository $menus;
private PostRepository $posts; private PostRepository $posts;
private SiteSettings $site;
private RedirectRepository $redirects;
private string $path; private string $path;
protected function setUp(): void protected function setUp(): void
@@ -47,6 +52,7 @@ final class CmsTest extends TestCase
(new Version20260912000500($this->db, new NullLogger()))->up($schema); (new Version20260912000500($this->db, new NullLogger()))->up($schema);
(new Version20260912000600($this->db, new NullLogger()))->up($schema); (new Version20260912000600($this->db, new NullLogger()))->up($schema);
(new Version20260912000700($this->db, new NullLogger()))->up($schema); (new Version20260912000700($this->db, new NullLogger()))->up($schema);
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) { foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql); $this->db->executeStatement($sql);
} }
@@ -59,13 +65,24 @@ final class CmsTest extends TestCase
$this->pages = new PageRepository($this->db, new DocumentValidator()); $this->pages = new PageRepository($this->db, new DocumentValidator());
$this->menus = new MenuRepository($this->db); $this->menus = new MenuRepository($this->db);
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media')); $this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->site = new SiteSettings($this->db);
$this->redirects = new RedirectRepository($this->db);
} }
protected function tearDown(): void protected function tearDown(): void
{ {
$this->db->close(); $this->db->close();
gc_collect_cycles(); gc_collect_cycles();
unlink($this->path); // Each request() boots its own kernel with its own connection to the same file. On Windows
// the handle is sometimes released a moment after the kernel is gone, so a single unlink
// can fail on a database that is otherwise finished with. Retry briefly, then leave the
// temporary file to the operating system rather than failing a passing test.
for ($attempt = 0; $attempt < 20 && file_exists($this->path); ++$attempt) {
if (@unlink($this->path)) {
return;
}
usleep(25000);
}
} }
public function testDocumentRoundTripAndDraftPrivacy(): void public function testDocumentRoundTripAndDraftPrivacy(): void
@@ -856,6 +873,181 @@ final class CmsTest extends TestCase
$this->pages->saveDraft('page-1', $old, 1); $this->pages->saveDraft('page-1', $old, 1);
} }
public function testSitemapListsOnlyPublishedIndexablePagesAndPointsRobotsAtIt(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$hidden = $this->publishedPage('intern', 'Intern');
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
$this->publishedPage('offen', 'Offen');
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
$sitemap = $this->request('/sitemap.xml');
self::assertSame(200, $sitemap->getStatusCode());
self::assertStringContainsString('application/xml', (string) $sitemap->headers->get('Content-Type'));
$xml = $sitemap->getContent();
self::assertNotFalse(simplexml_load_string($xml), 'The sitemap must be well-formed XML.');
self::assertStringContainsString('<loc>https://cms.test/</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
// A noindex page and an unpublished page have no business in a sitemap.
self::assertStringNotContainsString('/intern', $xml);
self::assertStringNotContainsString('/entwurf', $xml);
$robots = $this->request('/robots.txt');
self::assertSame(200, $robots->getStatusCode());
self::assertStringContainsString('text/plain', (string) $robots->headers->get('Content-Type'));
self::assertStringContainsString('Disallow: /admin', $robots->getContent());
self::assertStringContainsString('Sitemap: https://cms.test/sitemap.xml', $robots->getContent());
// A page excluded from indexing says so on the page itself as well.
self::assertStringContainsString('<meta name="robots" content="noindex, follow">', $this->request('/intern')->getContent());
self::assertStringNotContainsString('name="robots"', $this->request('/offen')->getContent());
}
public function testRedirectsAnswerBeforeTheNotFoundAndRefuseChainsAndLoops(): void
{
$this->publishedPage('leistungen', 'Leistungen');
$this->redirects->save('alte-seite', 'leistungen', 301);
$this->redirects->save('weg', '', 410);
$moved = $this->request('/alte-seite');
self::assertSame(301, $moved->getStatusCode());
self::assertSame('/leistungen', $moved->headers->get('Location'));
// A deliberately removed address is gone, not merely missing.
self::assertSame(410, $this->request('/weg')->getStatusCode());
self::assertSame(404, $this->request('/nie-dagewesen')->getStatusCode());
foreach ([['alte-seite', 'alte-seite', 301], ['noch-eine', 'alte-seite', 301], ['Gross', 'leistungen', 301], ['ok', 'leistungen', 302]] as [$from, $to, $status]) {
try {
$this->redirects->save($from, $to, $status);
self::fail(sprintf('Expected /%s -> /%s (%d) to be rejected.', $from, $to, $status));
} catch (InvalidPage) {
}
}
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect'));
// An existing page always wins over a redirect with the same address.
$this->redirects->save('leistungen', 'offen', 301);
self::assertSame(200, $this->request('/leistungen')->getStatusCode());
}
public function testStructuredDataComesOnlyFromMaintainedValues(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// Nothing maintained yet: no empty organisation block is emitted.
self::assertStringNotContainsString('application/ld+json', $this->request('/')->getContent());
$this->site->save([
'legalName' => 'Kant-IT Solutions', 'phone' => '+49 7221 9957150', 'email' => 'info@kant-it.de',
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'de',
'sameAs' => "https://hausklar.app\nhttps://hausklar.app", 'localBusiness' => '1',
]);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<script type="application/ld+json">', $html);
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $match);
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
self::assertSame('LocalBusiness', $data['@type']);
self::assertSame('Kant-IT Solutions', $data['name']);
self::assertSame('Baden-Baden', $data['address']['addressLocality']);
self::assertSame('DE', $data['address']['addressCountry']);
// Duplicates are collapsed, and nothing is invented.
self::assertSame(['https://hausklar.app'], $data['sameAs']);
self::assertArrayNotHasKey('description', $data);
// A post carries Article data built from its own metadata.
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01');
self::assertNotNull($this->pages->findForEditor($post));
$article = $this->request('/ein-beitrag')->getContent();
self::assertStringContainsString('"@type":"Article"', $article);
self::assertStringContainsString('"headline":"Ein Beitrag"', $article);
self::assertStringContainsString('"name":"Umut"', $article);
}
public function testBreadcrumbTrailAndItsStructuredDataFollowTheStoredHierarchy(): void
{
$parent = $this->publishedPage('leistungen', 'Leistungen');
self::assertNotNull($this->pages->findForEditor($parent));
$childId = $this->pages->createPage('webdesign', $parent, 'page', (static function (): array {
$document = DemoContent::starter('Webdesign');
$document['schemaVersion'] = 9;
$document['blocks'][] = ['id' => 'krumen', 'type' => 'breadcrumb'];
return $document;
})());
$child = $this->pages->findForEditor($childId);
$this->pages->publish($childId, $child['draft_revision_id'], $child['version']);
$html = $this->request('/leistungen/webdesign')->getContent();
self::assertStringContainsString('aria-label="Brotkrumen"', $html);
self::assertStringContainsString('<a href="/leistungen">Leistungen</a>', $html);
self::assertStringContainsString('<span aria-current="page">Webdesign</span>', $html);
self::assertStringContainsString('"@type":"BreadcrumbList"', $html);
self::assertStringContainsString('"item":"https://cms.test/leistungen"', $html);
// A page directly under the root gets no trail, so no empty navigation appears.
self::assertStringNotContainsString('aria-label="Brotkrumen"', $this->request('/leistungen')->getContent());
}
public function testLlmsTxtIsOffByDefaultAndListsOnlyPublicIndexableContent(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
self::assertSame(404, $this->request('/llms.txt')->getStatusCode());
$hidden = $this->publishedPage('intern', 'Intern');
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->site->save(['llmsTxt' => '1', 'legalName' => 'Kant-IT', 'description' => 'Ihr IT-Partner.', 'country' => 'DE']);
$response = $this->request('/llms.txt');
self::assertSame(200, $response->getStatusCode());
$body = $response->getContent();
self::assertStringContainsString('# Kant-IT', $body);
self::assertStringContainsString('> Ihr IT-Partner.', $body);
self::assertStringContainsString('https://cms.test/', $body);
self::assertStringNotContainsString('Intern', $body);
self::assertStringNotContainsString('Entwurf', $body);
}
/**
* @return iterable<string, array{array}>
*/
public static function rejectedSettings(): iterable
{
yield 'invalid email' => [['email' => 'keine-adresse', 'country' => 'DE']];
yield 'invalid phone' => [['phone' => 'ruf an', 'country' => 'DE']];
yield 'invalid country' => [['country' => 'Deutschland']];
yield 'insecure profile' => [['country' => 'DE', 'sameAs' => 'http://example.com']];
yield 'local business without address' => [['country' => 'DE', 'localBusiness' => '1']];
}
#[DataProvider('rejectedSettings')]
public function testRejectsInvalidCompanyDataWithoutWriting(array $input): void
{
try {
$this->site->save($input);
self::fail('Expected the settings to be rejected.');
} catch (InvalidPage) {
}
self::assertNull($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
}
public function testRobotsExtraOnlyAcceptsPlainDirectiveLines(): void
{
$this->site->save([
'country' => 'DE',
'robotsExtra' => "Disallow: /geheim\n# ein Kommentar\n<script>alert(1)</script>\nSitemap: https://fremde.example/sitemap.xml",
]);
$body = $this->request('/robots.txt')->getContent();
self::assertStringContainsString('Disallow: /geheim', $body);
self::assertStringContainsString('# ein Kommentar', $body);
// Neither markup nor a second sitemap declaration survives the filter.
self::assertStringNotContainsString('<script>', $body);
self::assertStringNotContainsString('fremde.example', $body);
self::assertSame(1, substr_count($body, 'Sitemap:'));
}
private function request(string $url, string $method = 'GET'): Response private function request(string $url, string $method = 'GET'): Response
{ {
$previous = []; $previous = [];
+2
View File
@@ -19,6 +19,7 @@ use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500; use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600; use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700; use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
@@ -53,6 +54,7 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000500($this->db, new NullLogger()))->up($schema); (new Version20260912000500($this->db, new NullLogger()))->up($schema);
(new Version20260912000600($this->db, new NullLogger()))->up($schema); (new Version20260912000600($this->db, new NullLogger()))->up($schema);
(new Version20260912000700($this->db, new NullLogger()))->up($schema); (new Version20260912000700($this->db, new NullLogger()))->up($schema);
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) { foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql); $this->db->executeStatement($sql);
} }
+2
View File
@@ -15,6 +15,7 @@ use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500; use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600; use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700; use DoctrineMigrations\Version20260912000700;
use DoctrineMigrations\Version20260912000800;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
$directory = dirname(__DIR__).'/var/browser-tests'; $directory = dirname(__DIR__).'/var/browser-tests';
@@ -32,6 +33,7 @@ $schema = new Schema();
(new Version20260912000500($db, new NullLogger()))->up($schema); (new Version20260912000500($db, new NullLogger()))->up($schema);
(new Version20260912000600($db, new NullLogger()))->up($schema); (new Version20260912000600($db, new NullLogger()))->up($schema);
(new Version20260912000700($db, new NullLogger()))->up($schema); (new Version20260912000700($db, new NullLogger()))->up($schema);
(new Version20260912000800($db, new NullLogger()))->up($schema);
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) { foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
$db->executeStatement($sql); $db->executeStatement($sql);
} }