Messen statt vermuten: 18 Abfragen auf 8, Ladebudget, Blaettern ueberall

Arbeitspaket M sagte "Abfragen je Seite zaehlen. Messen, dann zusammenfassen."
Messen braucht ein Messgeraet, also gibt es jetzt eines: ein Zaehler als
Doctrine-Middleware, dessen Zahl ausserhalb der Produktion im Kopf
X-Kant-Queries steht. Keine Profiler-Maschinerie, eine Zahl.

Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer
komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je
Aufrufer dieselbe Zeile erneut holten - Renderer, strukturierte Daten,
Einwilligungsschalter, Vorlagen. Beide merken sich ihren Stand jetzt fuer die
Dauer einer Anfrage: 18 auf 8.

Dabei ist mir die klassische Falle des Merkens selbst passiert, und ein
bestehender Test hat sie sofort gezeigt: ich leerte den Merker am Anfang des
Speicherns. Das Speichern liest den Stand aber noch einmal und fuellte ihn
damit erneut - der Rest der Anfrage bekam den alten Stand. Geleert wird jetzt
nach dem Schreiben.

Das Ladebudget wird gemessen statt behauptet: die Browserpruefung liest je
Seite HTML-Groesse, CSS-Groesse, Anzahl Anfragen, Anzahl Skripte und
Abfragezahl und prueft Obergrenzen. Startseite: 7,4 KB HTML, 22,7 KB CSS, 2
Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl - sie haelt
als Test fest, was bisher nur Absicht war.

Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das groesste
Ding ueber der Falz, und es zu verzoegern verzoegert genau das Pixel, auf das
jemand wartet. Der Renderer bestimmt es in Renderreihenfolge, Kopfbereich
zuerst.

Eine feste Obergrenze ist kein Blaettern: der Fassungsverlauf hoerte bei
dreissig auf, die Einsendungen bei fuenfzig, die Weiterleitungen zeigten bis
zu zweitausend auf einmal. Das Erste sieht aus wie "es gibt dreissig" und ist
das Einzige, was eine Historie nicht tun darf. Alle drei blaettern jetzt, mit
derselben Rechnung und demselben Pager wie Beitragsarchiv und Mediathek.

347 PHP-Tests / 1422 Assertions unter PHP 8.2.12 und 8.4.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 21:56:09 +02:00
1 parent 8c49f6a67a
commit 269fb223af
27 files changed
+480 -54

No files matched your search

+5 -1
View File
@@ -7,7 +7,7 @@ services:
exclude: '../src/{Kernel.php,Domain/}'
Doctrine\DBAL\Connection:
factory: ['App\Infrastructure\ConnectionFactory', 'create']
arguments: ['%env(resolve:DATABASE_URL)%']
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
App\Controller\PageController:
arguments:
$secret: '%kernel.secret%'
@@ -37,3 +37,7 @@ services:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
App\Infrastructure\QueryCounter: ~
App\EventSubscriber\QueryHeaderSubscriber:
arguments:
$environment: '%kernel.environment%'
+16
View File
@@ -440,6 +440,22 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
## ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
Arbeitspaket M sagte „Abfragen je Seite zählen. Messen, dann zusammenfassen.“ Messen braucht ein Messgerät, also gibt es jetzt eines.
**Ein Zähler als Doctrine-Middleware** zählt die Datenbankabfragen einer Anfrage, und außerhalb der Produktion steht die Zahl im Kopf `X-Kant-Queries`. Kein Profiler, kein Log – eine Zahl, damit „wie oft fragt diese Seite nach“ messbar statt geraten ist.
Die erste Messung: **die Startseite brauchte 18 Abfragen.** Nicht wegen einer komplizierten Seite, sondern weil `SiteSettings::load()` und `Theme::load()` je Aufrufer einmal dieselbe Zeile holten – der Renderer, die strukturierten Daten, der Einwilligungsschalter, die Vorlagen. Beide merken sich ihr Ergebnis jetzt für die Dauer einer Anfrage. **Ergebnis: 18 → 8.**
Dabei ist mir die klassische Falle des Merkens einmal selbst passiert, und der Test hat sie sofort gezeigt: ich habe den Merker am *Anfang* des Speicherns geleert. Das Speichern liest den Stand aber noch einmal (um die Vorlagen nicht zu verlieren), füllt den Merker damit erneut – und schreibt danach. Der Rest der Anfrage bekam den alten Stand. Geleert wird **nach** dem Schreiben.
**Das Ladebudget wird gemessen, nicht behauptet.** Die Browserprüfung liest je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und die Abfragezahl und prüft Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, **0 Skripte**. Die Null ist die wichtigste Zahl davon – sie hält die Zusage fest, dass eine öffentliche Seite ohne JavaScript vollständig ist, und zwar als Test statt als Absicht.
**Das erste Bild einer Seite ist nicht mehr `lazy`.** Es ist meistens das größte Ding über der Falz, und es zu verzögern verzögert genau das Pixel, auf das jemand wartet. Der Renderer bestimmt es in Renderreihenfolge – Kopfbereich zuerst, dann der Seiteninhalt – und gibt ihm `loading="eager"` und `fetchpriority="high"`. Alles darunter bleibt `lazy`.
**Eine feste Obergrenze ist kein Blättern.** Der Fassungsverlauf hörte bei dreißig auf, die Einsendungen bei fünfzig, die Weiterleitungen zeigten alle bis zu zweitausend auf einmal. Das Erste sieht aus wie „es gibt dreißig“ und ist das Einzige, was eine Historie nicht tun darf. Alle drei blättern jetzt, mit derselben Rechnung (`Pagination`) und demselben Pager wie Beitragsarchiv und Mediathek.
## ADR 038 – Sicherung: die Wiederherstellung ist die Funktion (12.09.2026)
Umut wollte eine Standard-Sicherungsfunktion. Gebaut ist sie so, dass der Teil im Mittelpunkt steht, den sonst niemand übt.
+2 -2
View File
@@ -178,7 +178,7 @@ Umut: „Als todo noch statistik kannst du das einbauen. Wieviel wann wo wie die
**Umsetzung:** ein Abonnent, der nach der Antwort zählt (nur `text/html`, nur Status 200/404, Verwaltung und Assets ausgenommen, Bots per User-Agent-Muster verworfen), ein Bildschirm „Statistik“ mit Tages- und Wochensicht als serverseitig gezeichnetes SVG-Balkendiagramm ohne JavaScript, Aufbewahrung 14 Monate mit Verdichtung auf Tageswerte nach 60 Tagen, und ein Export als CSV. Abnahme: ein Aufruf erhöht genau einen Zähler; ein Aufruf der Verwaltung erhöht keinen; die Tabelle enthält nach 1000 Aufrufen keine Spalte, aus der eine Person hervorgeht. **Alle drei erfüllt.** Umgesetzt ist zusätzlich der CSV-Export und `cms:stats:prune` für Verdichtung und Löschung. Abweichung vom Plan: die Gerätegrößen-Klasse entfällt, weil sie ohne Skript nicht feststellbar ist – das stand schon im Plan und ist so geblieben.
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026)
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026) — **weitgehend erledigt am 12.09.2026** (ADR 039)
*Braucht: nichts.*
@@ -189,7 +189,7 @@ Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchge
- **Ein Ladebudget je Seite**, in der Browserprüfung nachgemessen statt geschätzt: HTML, CSS und Bilder je Seite mit einer Obergrenze, die bricht, wenn sie überschritten wird. Heute lädt eine öffentliche Seite kein JavaScript, zwei bis drei Stylesheets und die Bilder – das soll so bleiben und nachweisbar sein.
- **Abfragen je Seite zählen.** Eine Seite mit zehn verknüpften Sektionen darf nicht zehn Einzelabfragen auslösen. Messen, dann zusammenfassen.
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft.
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft. **Beides erfüllt.** Offen bleibt aus diesem Paket: `srcset` ist für Bilder aus der Mediathek vorhanden, aber eine Seite mit vielen Bildern ist nie gegen das Budget gemessen worden, und die Benutzerliste blättert noch nicht (sie ist naturgemäß kurz).
### N – Installer (Nutzerauftrag vom 12.09.2026)
+9
View File
@@ -1,5 +1,14 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Ladeverhalten, 12.09.2026
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
- **Die Messung hat sofort etwas gefunden:** die Startseite brauchte 18 Datenbankabfragen, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden. Mit einem Merker für die Dauer einer Anfrage sind es 8. Das ist der Unterschied zwischen „vermutlich in Ordnung“ und einer Zahl.
- **Und die Messung hat einen Fehler von mir gefunden**, den ich beim Merken eingebaut hatte: der Merker wurde am Anfang des Speicherns geleert, das Speichern liest den Stand aber noch einmal und füllte ihn damit erneut – der Rest der Anfrage bekam den alten Stand. Ein bestehender Test über die Gestaltungsvorlagen fiel sofort um. Geleert wird jetzt nach dem Schreiben.
- Das Ladebudget prüft je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und Abfragen gegen Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, 0 Skripte; Beitragsübersicht 3,5 KB HTML, 29,8 KB CSS, 3 Anfragen, 13 Abfragen, 0 Skripte. **Die Null bei den Skripten ist die wichtigste Zahl** – sie hält als Test fest, was bisher nur Absicht war.
- Blättern ist mit 30 Fassungen und 12 Weiterleitungen geprüft, einschließlich einer Seitenzahl jenseits des Endes.
- **Nicht geprüft:** die Grenzen sind an der heutigen Demoseite gemessen; eine Seite mit vielen Bildern wird andere Zahlen haben, und die Obergrenzen müssen dann begründet angehoben werden statt stillschweigend. Auf echtem Hosting ist nichts davon gemessen (Arbeitspaket J).
## Ergänzung: Sicherung und Wiederherstellung, 12.09.2026
- **344 PHP-Tests / 1397 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
+5 -1
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -25,6 +25,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Fassungsverlauf, Einsendungen und Weiterleitungen blättern** statt bei 30, 50 beziehungsweise 2000 Einträgen stillschweigend aufzuhören.
- **Sicherung und Wiederherstellung** (ADR 038, Arbeitspaket O): `cms:backup` schreibt Datenbank (über `VACUUM INTO`, nicht als Dateikopie) und Medienordner nach `var/backups/`, mit Manifest samt Prüfsumme, und rotiert die alten. In der Verwaltung gibt es die Übersicht mit Zustand je Sicherung und einen Knopf zum Anlegen. **Zurückspielen nur auf der Konsole** (`cms:restore`): Prüfsumme, Fassungsvergleich, Sicherheitskopie des aktuellen Stands, Wartungszustand, Datenbank ersetzen, Medien **ergänzen** statt ersetzen. Nicht enthalten: `.env`, `vendor/`, Quellcode – steht so im Manifest.
- **Seiten ohne Menü, andere Köpfe je Seite** (ADR 037, Dokumentschema v16): jede Seite wählt Kopf- und Fußbereich – Standard, ein anderer Bereich oder **keiner**, für eine Landingpage ohne Navigation. Dafür sind mehrere Kopf- und Fußbereiche erlaubt, jeder mit eigener Adresse; der erste bleibt `default`. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück statt die Navigation stillschweigend zu verlieren.
- **Vier Menüplätze statt zwei** (Hauptnavigation, zweite Navigation, Fußzeile, Rechtliches) und **drei Menüarten**: waagerecht, untereinander, aufklappbar als Burger. Der Burger ist ein `details`-Element und öffnet ohne JavaScript.
@@ -80,6 +83,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
- Ladeverhalten geprüft: drei Tests – 30 Fassungen ergeben zwei Seiten mit richtiger Reihenfolge und richtigem Rest, eine Seite jenseits des Endes zeigt die letzte statt einer leeren; das erste Bild trägt `loading="eager"` und `fetchpriority="high"`, das zweite `lazy`; und die Startseite bleibt unter zwölf Abfragen. Dazu das Ladebudget in der Browserprüfung.
- **Wiederherstellung nachgewiesen**, nicht nur gebaut – das war der älteste offene Punkt aus Arbeitspaket I. Acht Tests gegen eine wegwerfbare Installation, darunter der eigentliche: Sicherung anlegen, Seite und Bilddatei löschen, zurückspielen, beides ist wieder da. Dazu von Hand auf der Entwicklungsinstallation: Testseite angelegt (5 Seiten), gesichert, gelöscht (4), zurückgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Ebenfalls geprüft: eine manipulierte Sicherung wird abgewiesen, eine aus einer neueren Fassung nur mit `--force`, Rotation behält die neuesten, und eine nach der Sicherung hochgeladene Datei überlebt die Wiederherstellung.
- Seitenbereiche geprüft: Standard, anderer Bereich, „keiner“ (dann steht weder `site-header` noch `site-footer` im HTML, die Seite selbst aber schon), ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, eine veraltete Fassungsnummer und eine ungültige Adresse werden abgewiesen. Menüarten: drei angenommen, eine erfundene abgewiesen, Burger als `details` – und die Seite behält `script-src 'none'`. Seitenbreite: die Regel in `site.css` nimmt den Theme-Wert, und ein größerer Wert erscheint als `--k-container` im Seiten-Stylesheet.
- Vollbild geprüft: vier Abschnittsbreiten werden angenommen und eine erfundene abgewiesen; sieben Höhenangaben, darunter dvh, svh und lvh, und zwei ungültige; ein randloser Abschnitt mit formatfüllendem Bild erzeugt `#id{width:100%}` und `#id img{object-fit:cover;height:100dvh}`. Die Hero-Vorlage ist im JavaScript-Modelltest nachgerechnet, Eigenschaft für Eigenschaft.
+33
View File
@@ -22,6 +22,39 @@ try {
results.push({ name: 'machine readable routes reachable', paths: 3 });
await context.close();
// A load budget, measured rather than assumed: what a page actually transfers, and how many
// database queries it needed. Both are ceilings - a page that starts asking once per section
// or shipping a megabyte of CSS fails here instead of only getting slower.
{
const budget = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await budget.newPage();
const transferred = new Map();
page.on('response', async response => {
const type = (response.headers()['content-type'] ?? '').split(';')[0];
const length = Number(response.headers()['content-length'] ?? 0);
transferred.set(response.url(), { type, length });
});
for (const path of ['/', '/beitraege']) {
transferred.clear();
const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
const html = (await response.text()).length;
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
const requests = transferred.size;
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
// A public page runs no script unless it carries tabs or its own; the budget keeps that honest.
assert.equal(scripts, 0, `${path}: ${scripts} Skript(e) geladen`);
assert.ok(requests <= 12, `${path}: ${requests} Anfragen, Obergrenze 12`);
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
results.push({ name: `load budget ${path}`, html, styles, requests, queries });
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen, ${queries} Abfragen, ${scripts} Skripte`);
}
await budget.close();
}
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 } });
const page = await context.newPage();
+11 -4
View File
@@ -253,10 +253,15 @@ final class FormRepository
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
}
/** @return list<array{id: string, form: string, data: array, createdAt: string, consentAt: ?string, delivered: bool}> */
public function submissions(int $limit = 100): array
/**
* One page of the submissions, newest first.
*
* @return array{items: list<array>, page: int, pages: int, total: int}
*/
public function submissions(int $number = 1, int $perPage = 25): array
{
return array_map(static fn (array $row): array => [
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), $perPage);
$items = array_map(static fn (array $row): array => [
'id' => $row['id'],
'form' => $row['name'] ?? $row['form_id'],
'data' => json_decode($row['data'], true, 512, JSON_THROW_ON_ERROR),
@@ -264,7 +269,9 @@ final class FormRepository
'consentAt' => $row['consent_at'],
'delivered' => (bool) $row['delivered'],
], $this->db->fetchAllAssociative(
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.max(1, min($limit, 500)),
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
}
+14 -3
View File
@@ -208,11 +208,20 @@ final class PageRepository
}
/** @return list<array{id: string, createdAt: string, title: string, published: bool, draft: bool}> */
public function revisions(string $pageId, int $limit = 30): array
/**
* One page of the history, newest first.
*
* It used to stop at thirty without saying so - which looks like "there are thirty" and is
* the one thing a history must not do.
*
* @return array{items: list<array>, page: int, pages: int, total: int}
*/
public function revisions(string $pageId, int $number = 1, int $perPage = 25): array
{
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$pageId]), $perPage);
$page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]);
return array_map(static function (array $row) use ($page): array {
$items = array_map(static function (array $row) use ($page): array {
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
return [
@@ -223,9 +232,11 @@ final class PageRepository
'draft' => $row['id'] === ($page['draft_revision_id'] ?? null),
];
}, $this->db->fetchAllAssociative(
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.max(1, min($limit, 100)),
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
[$pageId],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
/**
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Content;
/**
* The arithmetic every paged list needs, in one place.
*
* A fixed upper limit is not pagination: it shows the first thirty entries and says nothing about
* the rest. This makes "there are more, here is how to reach them" cheap enough that no list has
* to settle for a cap.
*/
final class Pagination
{
/**
* @return array{page: int, pages: int, offset: int, perPage: int, total: int}
*/
public static function of(int $page, int $total, int $perPage): array
{
$perPage = max(1, min(200, $perPage));
$pages = max(1, (int) ceil($total / $perPage));
// A page beyond the end shows the last one rather than an empty screen.
$page = max(1, min($page, $pages));
return [
'page' => $page,
'pages' => $pages,
'offset' => ($page - 1) * $perPage,
'perPage' => $perPage,
'total' => $total,
];
}
}
+15 -4
View File
@@ -17,12 +17,23 @@ final class RedirectRepository
{
}
/** @return list<array{id: string, from: string, to: ?string, status: int}> */
public function all(): array
/**
* One page of the redirects. There can be up to MAX of them, and a list of two thousand rows
* is not a list anybody reads.
*
* @return array{items: list<array{id: string, from: string, to: ?string, status: int}>, page: int, pages: int, total: int}
*/
public function all(int $number = 1, int $perPage = 50): array
{
return array_map(static fn (array $row): array => [
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default']), $perPage);
$items = 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']));
], $this->db->fetchAllAssociative(
'SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
['default'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
/** @return array{to: ?string, status: int}|null */
+14 -1
View File
@@ -40,12 +40,24 @@ final class SiteSettings
{
}
/**
* Remembered for the length of one request.
*
* These settings are asked for several times per page - by the renderer, by the structured
* data, by the consent gate, by the templates - and each time it was the same row again. The
* value is dropped on every save, so nothing stale survives a change.
*/
private ?array $loaded = null;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
$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 : [])];
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
}
public function save(array $input): void
@@ -132,6 +144,7 @@ final class SiteSettings
}
}
$this->loaded = null;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
+14 -2
View File
@@ -56,15 +56,21 @@ final class Theme
{
}
/** Remembered for one request, like the settings; dropped on every write. */
private ?array $loaded = null;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
return [...self::DEFAULTS, 'presets' => []];
return $this->loaded = [...self::DEFAULTS, 'presets' => []];
}
return [
return $this->loaded = [
'colors' => $stored['colors'],
'fonts' => $stored['fonts'],
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
@@ -97,6 +103,9 @@ final class Theme
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
private function colors(array $input): array
@@ -240,6 +249,9 @@ final class Theme
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
+2 -2
View File
@@ -249,14 +249,14 @@ final class EditorController extends AbstractController
}
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
public function revisions(string $id): Response
public function revisions(Request $request, string $id): Response
{
$page = $this->page($id);
return $this->render('admin/revisions.html.twig', [
'page' => $page,
'title' => $page['document']['title'] ?? $page['path'],
'revisions' => $this->pages->revisions($id),
'revisions' => $this->pages->revisions($id, $request->query->getInt('seite', 1)),
]);
}
+2 -2
View File
@@ -17,12 +17,12 @@ final class FormAdminController extends AbstractController
}
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
return $this->render('admin/forms.html.twig', [
'forms' => $this->forms->all(),
'types' => FormRepository::TYPES,
'submissions' => $this->forms->submissions(50),
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
]);
}
+35
View File
@@ -76,6 +76,8 @@ final class PageController extends AbstractController
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
]);
@@ -340,6 +342,39 @@ final class PageController extends AbstractController
return $response;
}
/**
* The first image of the page, in the order it is rendered.
*
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
*/
private function firstImage(array ...$documents): ?string
{
foreach ($documents as $document) {
$found = $this->findImage($document['blocks'] ?? []);
if (null !== $found) {
return $found;
}
}
return null;
}
private function findImage(array $blocks): ?string
{
foreach ($blocks as $block) {
if ('image' === ($block['type'] ?? null)) {
return $block['id'];
}
$inside = $this->findImage($block['children'] ?? []);
if (null !== $inside) {
return $inside;
}
}
return null;
}
/**
* The hosts of embeds that may load on this request.
*
+2 -2
View File
@@ -20,7 +20,7 @@ final class SettingsController extends AbstractController
}
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
$choosable = [];
foreach ($this->pages->listForEditor() as $page) {
@@ -33,7 +33,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [
'settings' => $this->settings->load(),
'redirects' => $this->redirects->all(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(),
@@ -0,0 +1,46 @@
<?php
namespace App\EventSubscriber;
use App\Infrastructure\QueryCounter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Puts the number of database queries of a request into a header.
*
* Only outside production: it is an instrument for the browser checks and for looking at a page
* while building it, not something a visitor should receive. The checks assert a ceiling with it,
* so a page that starts asking the database ten times per section fails a test instead of only
* getting slower.
*/
final class QueryHeaderSubscriber implements EventSubscriberInterface
{
public function __construct(private QueryCounter $counter, private string $environment)
{
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 4096],
KernelEvents::RESPONSE => ['onResponse', -512],
];
}
public function onRequest(RequestEvent $event): void
{
if ($event->isMainRequest()) {
$this->counter->reset();
}
}
public function onResponse(ResponseEvent $event): void
{
if ('prod' !== $this->environment && $event->isMainRequest()) {
$event->getResponse()->headers->set('X-Kant-Queries', (string) $this->counter->count());
}
}
}
+10 -2
View File
@@ -2,16 +2,24 @@
namespace App\Infrastructure;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Tools\DsnParser;
final class ConnectionFactory
{
public static function create(string $url): Connection
public static function create(string $url, ?QueryCounter $counter = null): Connection
{
$params = (new DsnParser(['sqlite' => 'pdo_sqlite', 'mysql' => 'pdo_mysql']))->parse($url);
$connection = DriverManager::getConnection($params);
$configuration = null;
if (null !== $counter) {
// Counting queries is a measuring instrument, not a feature: it is wired up outside
// the production environment, where the browser checks read the number.
$configuration = new Configuration();
$configuration->setMiddlewares([$counter]);
}
$connection = DriverManager::getConnection($params, $configuration);
if ('pdo_sqlite' === $params['driver']) {
$connection->executeStatement('PRAGMA foreign_keys = ON');
$connection->executeStatement('PRAGMA busy_timeout = 5000');
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Infrastructure;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Middleware;
use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
use Doctrine\DBAL\Driver\Result;
use Doctrine\DBAL\Driver\Statement as DriverStatement;
/**
* Counts the database queries of one request.
*
* Not a profiler and not a log: one number, so "how many queries does this page need" can be
* measured instead of guessed. It is the difference between a page with ten linked sections that
* asks once and one that asks ten times - and without counting, nobody notices the second kind
* until the site is slow on a real host.
*
* The number is attached as a header outside the production environment (see
* QueryHeaderSubscriber) and read by the browser checks.
*/
final class QueryCounter implements Middleware
{
private int $count = 0;
public function count(): int
{
return $this->count;
}
public function reset(): void
{
$this->count = 0;
}
public function record(): void
{
++$this->count;
}
public function wrap(Driver $driver): Driver
{
return new class($driver, $this) extends AbstractDriverMiddleware {
public function __construct(Driver $driver, private QueryCounter $counter)
{
parent::__construct($driver);
}
public function connect(array $params): DriverConnection
{
return new class(parent::connect($params), $this->counter) extends AbstractConnectionMiddleware {
public function __construct(DriverConnection $connection, private QueryCounter $counter)
{
parent::__construct($connection);
}
public function prepare(string $sql): DriverStatement
{
return new class(parent::prepare($sql), $this->counter) extends AbstractStatementMiddleware {
public function __construct(DriverStatement $statement, private QueryCounter $counter)
{
parent::__construct($statement);
}
public function execute(): Result
{
$this->counter->record();
return parent::execute();
}
};
}
public function query(string $sql): Result
{
$this->counter->record();
return parent::query($sql);
}
public function exec(string $sql): int|string
{
$this->counter->record();
return parent::exec($sql);
}
};
}
};
}
}
+4 -3
View File
@@ -73,12 +73,12 @@
</section>
{% endfor %}
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions|length }}</span></h2><span class="muted">Die neuesten 50.</span></div>
{% if submissions is empty %}
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
{% if submissions.items is empty %}
<div class="empty"><h3>Noch keine Einsendungen.</h3><p>Hier erscheinen eingegangene Nachrichten, auch wenn der E-Mail-Versand fehlschlägt.</p></div>
{% else %}
<div class="rows">
{% for entry in submissions %}
{% for entry in submissions.items %}
<article class="row row-top">
<div class="row-main">
<h3>{{ entry.form }}</h3>
@@ -90,5 +90,6 @@
{% endfor %}
</div>
{% endif %}
{% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %}
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{# One pager for every paged administration list: previous, position, next.
A fixed upper limit shows the first entries and says nothing about the rest; this says both. #}
{% if result.pages > 1 %}
<nav class="pager" aria-label="{{ label|default('Seiten') }}">
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
<span class="muted">Seite {{ result.page }} von {{ result.pages }} · {{ result.total }} Einträge</span>
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
+3 -2
View File
@@ -5,9 +5,9 @@
{% block lead %}Jedes Speichern erzeugt eine eigene Fassung. Eine frühere Fassung wiederherzustellen überschreibt nichts: sie wird als neuer Entwurf gespeichert, der bisherige Stand bleibt in der Liste.{% endblock %}
{% block actions %}<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}">Seite bearbeiten <span aria-hidden="true">→</span></a>{% endblock %}
{% block content %}
<div class="section-head"><h2>Verlauf <span class="count">{{ revisions|length }}</span></h2><span class="muted">Neueste zuerst, höchstens 30.</span></div>
<div class="section-head"><h2>Verlauf <span class="count">{{ revisions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
<div class="rows">
{% for revision in revisions %}
{% for revision in revisions.items %}
<article class="row">
<span class="row-icon" aria-hidden="true">⟲</span>
<div class="row-main">
@@ -28,5 +28,6 @@
<div class="empty"><h3>Noch keine Fassungen.</h3></div>
{% endfor %}
</div>
{% include 'admin/pager.html.twig' with {result: revisions, route: 'admin_revisions', params: {id: page.id}, label: 'Seiten des Verlaufs'} only %}
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+4 -3
View File
@@ -103,7 +103,7 @@
</section>
<section class="stack-section">
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects|length }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects.total }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
<form action="{{ path('admin_redirect_save') }}" method="post" class="card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<div class="fields">
@@ -127,12 +127,12 @@
<button class="primary" type="submit">Weiterleitung speichern</button>
</form>
{% if redirects is not empty %}
{% if redirects.items is not empty %}
<table class="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 %}
{% for entry in redirects.items %}
<tr>
<td><code>/{{ entry.from }}</code></td>
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
@@ -148,6 +148,7 @@
{% endfor %}
</tbody>
</table>
{% include 'admin/pager.html.twig' with {result: redirects, route: 'admin_settings', label: 'Seiten der Weiterleitungen'} only %}
{% endif %}
</section>
<p class="footnote">KantSites · Arbeitsstand</p>
+2 -2
View File
@@ -18,7 +18,7 @@
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -44,7 +44,7 @@
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
</footer>
{% endif %}
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
+10 -10
View File
@@ -10,7 +10,7 @@
{% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %}
</{{ tag }}>
{% elseif type == 'heading' %}
@@ -26,7 +26,7 @@
{% elseif type == 'button' %}
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
{% elseif type == 'image' %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% set eager = eagerImage|default(null) == block.id %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
{% elseif type == 'iconcard' %}
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
@@ -45,7 +45,7 @@
</figure>
{% elseif type == 'logos' %}
<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} 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([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %}
</ul>
{% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -88,7 +88,7 @@
{% set section = sections[props.partial]|default(null) %}
{% if section %}
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
{% elseif editing|default(false) %}
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
@@ -117,22 +117,22 @@
<ul class="tabs-nav">
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
</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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
{% elseif type == 'tabitem' %}
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</section>
{% elseif type == 'accordion' %}
<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
{% elseif type == 'accordionitem' %}
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
<summary>{{ props.title }}</summary>
<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
</details>
{% elseif type == 'icon' %}
@@ -161,7 +161,7 @@
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}"
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
{% for child in block.children|default([]) %}
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}</div>
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</div>
{% endfor %}
</div>
{% elseif type == 'embed' %}
@@ -199,7 +199,7 @@
</div>
{% elseif type == 'gallery' %}
<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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
{% else %}
{#
+3 -3
View File
@@ -40,7 +40,7 @@
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, eagerImage: eagerImage|default(null)} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -52,11 +52,11 @@
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
{% 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({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12} 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([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12, eagerImage: eagerImage|default(null)} only %}{% endfor %}
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, eagerImage: null} only %}{% endfor %}
</footer>
{% endif %}
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
+83 -5
View File
@@ -1303,16 +1303,19 @@ final class CmsTest extends TestCase
$second = DemoContent::starter('Zweite Fassung');
$this->pages->saveDraft('page-1', $first, 0);
$this->pages->saveDraft('page-1', $second, 1);
// The history is paged now, so it says how many there are instead of stopping silently.
$revisions = $this->pages->revisions('page-1');
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions, 'title'), 'Newest first.');
self::assertTrue($revisions[0]['draft']);
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions['items'], 'title'), 'Newest first.');
self::assertSame(2, $revisions['total']);
self::assertSame(1, $revisions['pages']);
self::assertTrue($revisions['items'][0]['draft']);
$oldest = end($revisions);
$oldest = end($revisions['items']);
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
// Restoring adds a revision instead of overwriting one, so nothing is lost.
self::assertCount(3, $this->pages->revisions('page-1'));
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')[1]['title']);
self::assertSame(3, $this->pages->revisions('page-1')['total']);
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')['items'][1]['title']);
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
$this->expectException(InvalidPage::class);
@@ -1781,6 +1784,81 @@ final class CmsTest extends TestCase
]);
}
public function testEveryLongListSaysHowMuchThereIsAndLetsYouReachIt(): void
{
// Thirty revisions: a fixed cap would show some and say nothing about the rest.
for ($index = 1; $index <= 30; ++$index) {
$this->pages->saveDraft('page-1', DemoContent::starter('Fassung '.$index), $index - 1);
}
$first = $this->pages->revisions('page-1', 1, 25);
self::assertCount(25, $first['items']);
self::assertSame(30, $first['total']);
self::assertSame(2, $first['pages']);
self::assertSame('Fassung 30', $first['items'][0]['title'], 'Newest first.');
$second = $this->pages->revisions('page-1', 2, 25);
self::assertCount(5, $second['items']);
self::assertSame('Fassung 1', end($second['items'])['title']);
// A page beyond the end shows the last one instead of an empty screen.
self::assertSame(2, $this->pages->revisions('page-1', 99, 25)['page']);
// The same for redirects, which may run into the thousands.
for ($index = 1; $index <= 12; ++$index) {
$this->redirects->save('alt-'.$index, 'home', 301);
}
$redirects = $this->redirects->all(1, 5);
self::assertCount(5, $redirects['items']);
self::assertSame(12, $redirects['total']);
self::assertSame(3, $redirects['pages']);
self::assertCount(2, $this->redirects->all(3, 5)['items']);
}
public function testTheFirstImageOfAPageIsNotLazy(): void
{
$document = DemoContent::starter('Mit Bildern');
$document['schemaVersion'] = 3;
foreach (['erstes', 'zweites'] as $id) {
$document['blocks'][0]['children'][] = [
'id' => $id, 'type' => 'image',
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 800, 'height' => 600],
];
}
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// The first one is usually the largest thing above the fold; deferring it delays exactly
// the pixel a visitor waits for.
self::assertMatchesRegularExpression('/<img[^>]*id="erstes"|id="erstes"[^>]*>/', $html);
$first = $this->imageTag($html, 'erstes');
$second = $this->imageTag($html, 'zweites');
self::assertStringContainsString('loading="eager"', $first);
self::assertStringContainsString('fetchpriority="high"', $first);
self::assertStringContainsString('loading="lazy"', $second);
self::assertStringNotContainsString('fetchpriority', $second);
}
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$response = $this->request('/');
$queries = (int) $response->headers->get('X-Kant-Queries');
// A ceiling, not an exact number: the point is that settings and theme are read once per
// request instead of once per caller. Before that they were read eighteen times.
self::assertGreaterThan(0, $queries, 'The counter has to be wired up at all.');
self::assertLessThanOrEqual(12, $queries, sprintf('Die Startseite braucht %d Abfragen.', $queries));
}
/** The img tag belonging to one block id, so the attributes can be read for that one image. */
private function imageTag(string $html, string $id): string
{
$figure = strpos($html, 'id="'.$id.'"');
self::assertNotFalse($figure, 'Bild '.$id.' fehlt.');
$start = strpos($html, '<img', $figure);
self::assertNotFalse($start);
return substr($html, $start, (int) strpos($html, '>', $start) - $start);
}
public function testAPageCanUseAnotherHeaderOrNoneAtAll(): void
{
// The seeded areas plus a second header for landing pages.